From d831b93934f8f5ffaa4985555d323721263403bc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 01:43:05 +0000 Subject: [PATCH 01/96] Allow deleting user account with active memberships Instead of blocking account deletion when the user has confirmed team memberships, handle memberships gracefully during deletion: - Sole owner + sole member: delete the team and queue project cleanup - Sole owner + other members: transfer ownership to the next member - Non-owner / multiple owners: no special handling needed (worker cleans up) Also update the Deletes worker to transfer the team's primary user reference when removing a deleted user's memberships. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 63 +++++++++++++-- src/Appwrite/Platform/Workers/Deletes.php | 16 +++- .../Account/AccountConsoleClientTest.php | 77 +++++++++++++------ 3 files changed, 126 insertions(+), 30 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d576bbce44..6ac6373c87 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -614,18 +614,71 @@ Http::delete('/v1/account') ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForDeletes') - ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) { + ->inject('authorization') + ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) { if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } if ($project->getId() === 'console') { - // get all memberships $memberships = $user->getAttribute('memberships', []); foreach ($memberships as $membership) { - // prevent deletion if at least one active membership - if ($membership->getAttribute('confirm', false)) { - throw new Exception(Exception::USER_DELETION_PROHIBITED); + if (!$membership->getAttribute('confirm', false)) { + continue; + } + + $team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId')); + if ($team->isEmpty()) { + continue; + } + + $isSoleOwner = false; + if (in_array('owner', $membership->getAttribute('roles', []))) { + $ownersCount = $dbForProject->count( + collection: 'memberships', + queries: [ + Query::contains('roles', ['owner']), + Query::equal('teamInternalId', [$team->getSequence()]) + ], + max: 2 + ); + $isSoleOwner = ($ownersCount === 1); + } + + $totalMembers = $team->getAttribute('total', 0); + + if ($isSoleOwner && $totalMembers <= 1) { + // User is the only owner and the only member — delete the team. + // The team deletion worker will clean up associated projects and resources. + $dbForProject->deleteDocument('teams', $team->getId()); + + $queueForDeletes + ->setType(DELETE_TYPE_TEAM_PROJECTS) + ->setDocument($team) + ->trigger(); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($team) + ->trigger(); + } elseif ($isSoleOwner) { + // User is the sole owner but other members exist — transfer ownership + // to the next member before removing this user's membership. + $nextMember = $dbForProject->findOne('memberships', [ + Query::equal('teamInternalId', [$team->getSequence()]), + Query::notEqual('userInternalId', $user->getSequence()), + ]); + + if (!$nextMember->isEmpty()) { + $roles = $nextMember->getAttribute('roles', []); + if (!in_array('owner', $roles)) { + $roles[] = 'owner'; + $authorization->skip(fn () => $dbForProject->updateDocument('memberships', $nextMember->getId(), new Document([ + 'roles' => $roles, + ]))); + $dbForProject->purgeCachedDocument('users', $nextMember->getAttribute('userId')); + } + } } } } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index c420444112..9508f784bd 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -845,12 +845,26 @@ class Deletes extends Action $this->deleteByGroup('memberships', [ Query::equal('userInternalId', [$userInternalId]), Query::orderAsc() - ], $dbForProject, function (Document $document) use ($dbForProject) { + ], $dbForProject, function (Document $document) use ($dbForProject, $userInternalId) { if ($document->getAttribute('confirm')) { // Count only confirmed members $teamId = $document->getAttribute('teamId'); $team = $dbForProject->getDocument('teams', $teamId); if (!$team->isEmpty()) { $dbForProject->decreaseDocumentAttribute('teams', $teamId, 'total', 1, 0); + + // If this user was the team's primary user, transfer to the next member + if ($team->getAttribute('userInternalId') === $userInternalId) { + $nextMembership = $dbForProject->findOne('memberships', [ + Query::equal('teamInternalId', [$team->getSequence()]), + ]); + + if ($nextMembership !== false && !$nextMembership->isEmpty()) { + $dbForProject->updateDocument('teams', $team->getId(), new Document([ + 'userId' => $nextMembership->getAttribute('userId'), + 'userInternalId' => $nextMembership->getAttribute('userInternalId'), + ])); + } + } } } }); diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 78f7798193..9f825c3c89 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -14,7 +14,12 @@ class AccountConsoleClientTest extends Scope use ProjectConsole; use SideClient; - public function testDeleteAccount(): void + /** + * Test that account deletion succeeds even with active team memberships. + * When the user is the sole owner and only member of a team, the team + * should be cleaned up automatically. + */ + public function testDeleteAccountWithMembership(): void { $email = uniqid() . 'user@localhost.test'; $password = 'password'; @@ -46,7 +51,7 @@ class AccountConsoleClientTest extends Scope $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; - // create team + // Create team — user becomes sole owner and only member $team = $this->client->call(Client::METHOD_POST, '/teams', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', @@ -58,7 +63,51 @@ class AccountConsoleClientTest extends Scope ]); $this->assertEquals($team['headers']['status-code'], 201); - $teamId = $team['body']['$id']; + // Account deletion should succeed even with active membership + $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + } + + /** + * Test that account deletion works when the user has no team memberships. + */ + public function testDeleteAccountWithoutMembership(): void + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ 'origin' => 'http://localhost', @@ -67,27 +116,7 @@ class AccountConsoleClientTest extends Scope 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); - $this->assertEquals($response['headers']['status-code'], 400); - - // DELETE TEAM - $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - $this->assertEquals($response['headers']['status-code'], 204); - - $this->assertEventually(function () use ($session) { - $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - - $this->assertEquals(204, $response['headers']['status-code']); - }, 10_000, 500); + $this->assertEquals(204, $response['headers']['status-code']); } public function testSessionAlert(): void From 16ed60a5c358a50cb3fd9ec606752efda8765156 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 02:02:09 +0000 Subject: [PATCH 02/96] Filter unconfirmed members when transferring team ownership Prevent unconfirmed (pending invite) members from being promoted to owner or set as the team's primary user during membership/account deletion by adding a Query::equal('confirm', [true]) filter to the relevant findOne queries. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 1 + src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php | 1 + src/Appwrite/Platform/Workers/Deletes.php | 1 + 3 files changed, 3 insertions(+) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6ac6373c87..83fc7465c9 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -667,6 +667,7 @@ Http::delete('/v1/account') $nextMember = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), Query::notEqual('userInternalId', $user->getSequence()), + Query::equal('confirm', [true]), ]); if (!$nextMember->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 3b516c2d60..d055ecb23f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,6 +126,7 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 9508f784bd..a138f7c93b 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -856,6 +856,7 @@ class Deletes extends Action if ($team->getAttribute('userInternalId') === $userInternalId) { $nextMembership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if ($nextMembership !== false && !$nextMembership->isEmpty()) { From 4297c70f58786f63c9ace630727baccd4b60b9e7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 05:22:02 +0000 Subject: [PATCH 03/96] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20safer=20orphan=20approach,=20veteran=20ordering,=20?= =?UTF-8?q?deduplicate=20transfer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove team deletion for sole owner+sole member case; let orphan teams be cleaned up by Cloud's inactive project cleanup (safer, avoids accidental data loss) - Add explicit ordering by $createdAt so the most veteran member gets ownership transfer, with limit(1) for clarity - Remove confirm filter on primary user transfer in membership deletion so all members (including unconfirmed) are considered - Remove redundant ownership transfer from Deletes worker since the API controller already handles it before queueing Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 19 +++---------------- .../Modules/Teams/Http/Memberships/Delete.php | 1 - src/Appwrite/Platform/Workers/Deletes.php | 15 --------------- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 83fc7465c9..db2d5d2a26 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -647,27 +647,14 @@ Http::delete('/v1/account') $totalMembers = $team->getAttribute('total', 0); - if ($isSoleOwner && $totalMembers <= 1) { - // User is the only owner and the only member — delete the team. - // The team deletion worker will clean up associated projects and resources. - $dbForProject->deleteDocument('teams', $team->getId()); - - $queueForDeletes - ->setType(DELETE_TYPE_TEAM_PROJECTS) - ->setDocument($team) - ->trigger(); - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($team) - ->trigger(); - } elseif ($isSoleOwner) { + if ($isSoleOwner && $totalMembers > 1) { // User is the sole owner but other members exist — transfer ownership // to the next member before removing this user's membership. $nextMember = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), Query::notEqual('userInternalId', $user->getSequence()), - Query::equal('confirm', [true]), + Query::orderAsc('$createdAt'), + Query::limit(1), ]); if (!$nextMember->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index d055ecb23f..3b516c2d60 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,7 +126,6 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), - Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index a138f7c93b..5476aee529 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -851,21 +851,6 @@ class Deletes extends Action $team = $dbForProject->getDocument('teams', $teamId); if (!$team->isEmpty()) { $dbForProject->decreaseDocumentAttribute('teams', $teamId, 'total', 1, 0); - - // If this user was the team's primary user, transfer to the next member - if ($team->getAttribute('userInternalId') === $userInternalId) { - $nextMembership = $dbForProject->findOne('memberships', [ - Query::equal('teamInternalId', [$team->getSequence()]), - Query::equal('confirm', [true]), - ]); - - if ($nextMembership !== false && !$nextMembership->isEmpty()) { - $dbForProject->updateDocument('teams', $team->getId(), new Document([ - 'userId' => $nextMembership->getAttribute('userId'), - 'userInternalId' => $nextMembership->getAttribute('userInternalId'), - ])); - } - } } } }); From 8f6530d1e7a92e89092294c07f38c47560b6ef48 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 05:34:04 +0000 Subject: [PATCH 04/96] fix: remove unused $userInternalId from closure Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Appwrite/Platform/Workers/Deletes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 5476aee529..c420444112 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -845,7 +845,7 @@ class Deletes extends Action $this->deleteByGroup('memberships', [ Query::equal('userInternalId', [$userInternalId]), Query::orderAsc() - ], $dbForProject, function (Document $document) use ($dbForProject, $userInternalId) { + ], $dbForProject, function (Document $document) use ($dbForProject) { if ($document->getAttribute('confirm')) { // Count only confirmed members $teamId = $document->getAttribute('teamId'); $team = $dbForProject->getDocument('teams', $teamId); From ba32012744108b430431ded3cb42d73bddfee486 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 07:11:32 +0000 Subject: [PATCH 05/96] fix: filter unconfirmed members from owner count, ownership transfer, and primary user transfer Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 4 +++- .../Platform/Modules/Teams/Http/Memberships/Delete.php | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index db2d5d2a26..5ff21eee7c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -638,7 +638,8 @@ Http::delete('/v1/account') collection: 'memberships', queries: [ Query::contains('roles', ['owner']), - Query::equal('teamInternalId', [$team->getSequence()]) + Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ], max: 2 ); @@ -653,6 +654,7 @@ Http::delete('/v1/account') $nextMember = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), Query::notEqual('userInternalId', $user->getSequence()), + Query::equal('confirm', [true]), Query::orderAsc('$createdAt'), Query::limit(1), ]); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 3b516c2d60..d055ecb23f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,6 +126,7 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { From cc82b1a5cf1d4e90282dd70b28e05a79be112569 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 07:15:35 +0000 Subject: [PATCH 06/96] fix: don't promote non-owners on account deletion, leave team orphaned instead Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 40 ++------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5ff21eee7c..f05a49c6bb 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -632,44 +632,8 @@ Http::delete('/v1/account') continue; } - $isSoleOwner = false; - if (in_array('owner', $membership->getAttribute('roles', []))) { - $ownersCount = $dbForProject->count( - collection: 'memberships', - queries: [ - Query::contains('roles', ['owner']), - Query::equal('teamInternalId', [$team->getSequence()]), - Query::equal('confirm', [true]), - ], - max: 2 - ); - $isSoleOwner = ($ownersCount === 1); - } - - $totalMembers = $team->getAttribute('total', 0); - - if ($isSoleOwner && $totalMembers > 1) { - // User is the sole owner but other members exist — transfer ownership - // to the next member before removing this user's membership. - $nextMember = $dbForProject->findOne('memberships', [ - Query::equal('teamInternalId', [$team->getSequence()]), - Query::notEqual('userInternalId', $user->getSequence()), - Query::equal('confirm', [true]), - Query::orderAsc('$createdAt'), - Query::limit(1), - ]); - - if (!$nextMember->isEmpty()) { - $roles = $nextMember->getAttribute('roles', []); - if (!in_array('owner', $roles)) { - $roles[] = 'owner'; - $authorization->skip(fn () => $dbForProject->updateDocument('memberships', $nextMember->getId(), new Document([ - 'roles' => $roles, - ]))); - $dbForProject->purgeCachedDocument('users', $nextMember->getAttribute('userId')); - } - } - } + // Team is left as-is — we don't promote non-owner members to owner. + // Orphan teams are cleaned up later by Cloud's inactive project cleanup. } } From d13dbae0feb7ad909cb3a86ece706f80253aac15 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 10 Apr 2026 13:59:45 +0530 Subject: [PATCH 07/96] added allowance of empty payload for documentsdb --- .../Collections/Documents/Create.php | 31 ++++++-- .../Collections/Documents/Create.php | 6 ++ .../e2e/Services/Databases/DatabasesBase.php | 79 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 38c84c4ae1..24cba578a9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -50,6 +50,11 @@ class Create extends Action return UtopiaResponse::MODEL_DOCUMENT_LIST; } + protected function getSupportForEmptyDocument() + { + return false; + } + public function __construct() { $this @@ -139,30 +144,42 @@ class Create extends Action ->inject('eventProcessor') ->callback($this->action(...)); } + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) : $data; + $supportsEmptyDocument = $this->getSupportForEmptyDocument(); + $hasData = !empty($data); + $hasDocuments = !empty($documents); + /** * Determine which internal path to call, single or bulk */ - if (empty($data) && empty($documents)) { + if (!$supportsEmptyDocument && !$hasData && !$hasDocuments) { // No single or bulk documents provided throw new Exception($this->getMissingDataException()); } - if (!empty($data) && !empty($documents)) { + + // When empty documents are supported, an empty payload should still be treated as single create. + if ($supportsEmptyDocument && !$hasData && !$hasDocuments) { + $data = []; + $hasData = true; + } + + if ($hasData && $hasDocuments) { // Both single and bulk documents provided throw new Exception(Exception::GENERAL_BAD_REQUEST, 'You can only send one of the following parameters: data, ' . $this->getSDKGroup()); } - if (!empty($data) && empty($documentId)) { + if ($hasData && empty($documentId)) { // Single document provided without document ID $document = $this->isCollectionsAPI() ? 'Document' : 'Row'; $message = "$document ID is required when creating a single " . strtolower($document) . '.'; throw new Exception($this->getMissingDataException(), $message); } - if (!empty($documents) && !empty($documentId)) { + if ($hasDocuments && !empty($documentId)) { // Bulk documents provided with document ID $documentId = $this->isCollectionsAPI() ? 'documentId' : 'rowId'; throw new Exception( @@ -170,13 +187,13 @@ class Create extends Action "Param \"$documentId\" is not allowed when creating multiple " . $this->getSDKGroup() . ', set "$id" on each instead.' ); } - if (!empty($documents) && !empty($permissions)) { + if ($hasDocuments && !empty($permissions)) { // Bulk documents provided with permissions throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "permissions" is disallowed when creating multiple ' . $this->getSDKGroup() . ', set "$permissions" on each instead'); } - $isBulk = true; - if (!empty($data)) { + $isBulk = $hasDocuments; + if ($hasData) { // Single document provided, convert to single item array // But remember that it was single to respond with a single document $isBulk = false; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php index 039a05ff50..532ae826e2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php @@ -34,6 +34,12 @@ class Create extends DocumentCreate return UtopiaResponse::MODEL_DOCUMENT_LIST; } + protected function getSupportForEmptyDocument() + { + return true; + } + + public function __construct() { $this diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 2c5e587fc2..5b2a401fd3 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -11539,4 +11539,83 @@ trait DatabasesBase $this->assertEquals('Product B', $rows['body'][$this->getRecordResource()][0]['name']); $this->assertEquals(139.99, $rows['body'][$this->getRecordResource()][0]['price']); } + public function testDocumentWithEmptyPaylod(): void + { + $data = $this->setupCollection(); + $databaseId = $data['databaseId']; + $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [], + 'permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ]); + if ($this->getSupportForAttributes()) { + $this->assertEquals(400, $document['headers']['status-code']); + } else { + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals($data['moviesId'], $document['body'][$this->getContainerIdResponseKey()]); + $this->assertArrayNotHasKey('$collection', $document['body']); + $this->assertEquals($databaseId, $document['body']['$databaseId']); + $this->assertTrue(array_key_exists('$sequence', $document['body'])); + $this->assertIsString($document['body']['$sequence']); + + $documentId = $document['body']['$id']; + + $fetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()) + ); + + $this->assertEquals(200, $fetched['headers']['status-code']); + $this->assertEqualsCanonicalizing([ + '$id', + '$databaseId', + '$createdAt', + '$updatedAt', + '$permissions', + '$sequence', + $this->getContainerIdResponseKey(), + ], \array_keys($fetched['body'])); + $this->assertFalse(array_key_exists('$tenant', $fetched['body'])); + + $updated = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'data' => [ + 'status' => 'draft', + ], + ] + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('draft', $updated['body']['status']); + + $refetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()) + ); + + $this->assertEquals(200, $refetched['headers']['status-code']); + $this->assertEquals('draft', $refetched['body']['status']); + } + } } From dc0a5c88b7390ede6f7a21b0b4ee97cf3f03f40e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 14:28:31 +0530 Subject: [PATCH 08/96] refactor: migrate audits certificates screenshots to publishers --- app/cli.php | 4 - app/controllers/general.php | 17 +-- app/controllers/shared/api.php | 53 ++++--- app/init/resources.php | 15 ++ app/init/resources/request.php | 20 ++- app/init/worker/message.php | 16 +-- src/Appwrite/Event/Context/Audit.php | 134 ++++++++++++++++++ src/Appwrite/Event/Message/Audit.php | 55 +++++++ src/Appwrite/Event/Message/Certificate.php | 43 ++++++ src/Appwrite/Event/Message/Screenshot.php | 37 +++++ src/Appwrite/Event/Publisher/Audit.php | 35 +++++ src/Appwrite/Event/Publisher/Certificate.php | 27 ++++ src/Appwrite/Event/Publisher/Screenshot.php | 27 ++++ .../Modules/Functions/Workers/Builds.php | 19 +-- .../Modules/Functions/Workers/Screenshots.php | 5 +- .../Health/Http/Health/Queue/Audits/Get.php | 8 +- .../Http/Health/Queue/Certificates/Get.php | 8 +- .../Health/Http/Health/Queue/Failed/Get.php | 24 ++-- .../Health/Http/Health/Queue/Logs/Get.php | 8 +- .../Modules/Proxy/Http/Rules/API/Create.php | 17 +-- .../Proxy/Http/Rules/Function/Create.php | 17 +-- .../Proxy/Http/Rules/Redirect/Create.php | 17 +-- .../Modules/Proxy/Http/Rules/Site/Create.php | 17 +-- .../Proxy/Http/Rules/Verification/Update.php | 15 +- src/Appwrite/Platform/Tasks/Interval.php | 34 +++-- src/Appwrite/Platform/Tasks/Maintenance.php | 26 ++-- src/Appwrite/Platform/Tasks/SSL.php | 19 +-- src/Appwrite/Platform/Workers/Audits.php | 35 ++--- .../Platform/Workers/Certificates.php | 41 +++--- 29 files changed, 592 insertions(+), 201 deletions(-) create mode 100644 src/Appwrite/Event/Context/Audit.php create mode 100644 src/Appwrite/Event/Message/Audit.php create mode 100644 src/Appwrite/Event/Message/Certificate.php create mode 100644 src/Appwrite/Event/Message/Screenshot.php create mode 100644 src/Appwrite/Event/Publisher/Audit.php create mode 100644 src/Appwrite/Event/Publisher/Certificate.php create mode 100644 src/Appwrite/Event/Publisher/Screenshot.php diff --git a/app/cli.php b/app/cli.php index 73908510d9..ee0b4a6103 100644 --- a/app/cli.php +++ b/app/cli.php @@ -2,7 +2,6 @@ require_once __DIR__ . '/init.php'; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; @@ -263,9 +262,6 @@ $container->set('queueForFunctions', function (Publisher $publisher) { $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); -$container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); -}, ['publisher']); $container->set('logError', function (Registry $register) { return function (Throwable $error, string $namespace, string $action) use ($register) { Console::error('[Error] Timestamp: ' . date('c', time())); diff --git a/app/controllers/general.php b/app/controllers/general.php index c6e2eacb33..53776e1ccc 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -7,9 +7,9 @@ use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Bus\Events\ExecutionCompleted; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Network\Cors; use Appwrite\Platform\Appwrite; @@ -1006,11 +1006,11 @@ Http::init() ->inject('request') ->inject('console') ->inject('dbForPlatform') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('platform') ->inject('authorization') ->inject('certifiedDomains') - ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) { + ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) { $hostname = $request->getHostname(); $platformHostnames = $platform['hostnames'] ?? []; @@ -1036,7 +1036,7 @@ Http::init() } // 4. Check/create rule (requires DB access) - $authorization->skip(function () use ($dbForPlatform, $domain, $console, $queueForCertificates, $certifiedDomains) { + $authorization->skip(function () use ($dbForPlatform, $domain, $console, $publisherForCertificates, $certifiedDomains) { try { // TODO: (@Meldiron) Remove after 1.7.x migration $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; @@ -1092,10 +1092,11 @@ Http::init() $dbForPlatform->createDocument('rules', $document); Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); - $queueForCertificates - ->setDomain($document) - ->setSkipRenewCheck(true) - ->trigger(); + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $console, + domain: $document, + skipRenewCheck: true, + )); } catch (Duplicate $e) { Console::info('Certificate already exists'); } finally { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fa96d2ae80..6c7532959b 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -3,8 +3,8 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Audit; use Appwrite\Event\Build; +use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -12,6 +12,7 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Messaging; +use Appwrite\Event\Publisher\Audit; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; @@ -88,7 +89,7 @@ Http::init() ->inject('request') ->inject('dbForPlatform') ->inject('dbForProject') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('project') ->inject('user') ->inject('session') @@ -97,7 +98,7 @@ Http::init() ->inject('team') ->inject('apiKey') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { $route = $utopia->getRoute(); if ($route === null) { throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); @@ -193,7 +194,7 @@ Http::init() 'name' => $apiKey->getName(), ]); - $queueForAudits->setUser($user); + $auditContext->setUser($user); } // For standard keys, update last accessed time @@ -264,7 +265,7 @@ Http::init() API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, default => ACTIVITY_TYPE_KEY_PROJECT, }); - $queueForAudits->setUser($userClone); + $auditContext->setUser($userClone); } // Apply permission @@ -477,7 +478,7 @@ Http::init() ->inject('user') ->inject('queueForEvents') ->inject('queueForMessaging') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('queueForDeletes') ->inject('queueForDatabase') ->inject('queueForBuilds') @@ -494,7 +495,7 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { $response->setUser($user); $request->setUser($user); @@ -595,7 +596,7 @@ Http::init() ->setProject($project) ->setUser($user); - $queueForAudits + $auditContext ->setMode($mode) ->setUserAgent($request->getUserAgent('')) ->setIP($request->getIP()) @@ -610,7 +611,7 @@ Http::init() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $queueForAudits->setUser($userClone); + $auditContext->setUser($userClone); } /* Auto-set projects */ @@ -789,7 +790,8 @@ Http::shutdown() ->inject('project') ->inject('user') ->inject('queueForEvents') - ->inject('queueForAudits') + ->inject('auditContext') + ->inject('publisherForAudits') ->inject('usage') ->inject('publisherForUsage') ->inject('queueForDeletes') @@ -806,7 +808,7 @@ Http::shutdown() ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -901,7 +903,7 @@ Http::shutdown() if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); if (! empty($resource) && $resource !== $pattern) { - $queueForAudits->setResource($resource); + $auditContext->setResource($resource); } } @@ -911,8 +913,8 @@ Http::shutdown() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $queueForAudits->setUser($userClone); - } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) { + $auditContext->setUser($userClone); + } elseif ($auditContext->getUser() === null || $auditContext->getUser()->isEmpty()) { /** * User in the request is empty, and no user was set for auditing previously. * This indicates: @@ -930,24 +932,31 @@ Http::shutdown() 'name' => 'Guest', ]); - $queueForAudits->setUser($user); + $auditContext->setUser($user); } - if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) { + $auditUser = $auditContext->getUser(); + if (! empty($auditContext->getResource()) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { - $queueForAudits->setPayload($responsePayload); + $auditContext->setPayload($responsePayload); } - foreach ($queueForEvents->getParams() as $key => $value) { - $queueForAudits->setParam($key, $value); - } - - $queueForAudits->trigger(); + $publisherForAudits->enqueue(new \Appwrite\Event\Message\Audit( + project: $auditContext->getProject() ?? new Document(), + user: $auditUser, + payload: $auditContext->getPayload(), + resource: $auditContext->getResource(), + mode: $auditContext->getMode(), + ip: $auditContext->getIP(), + userAgent: $auditContext->getUserAgent(), + event: $auditContext->getEvent(), + hostname: $auditContext->getHostname(), + )); } if (! empty($queueForDeletes->getType())) { diff --git a/app/init/resources.php b/app/init/resources.php index 32d6e0a45f..d1bb7584bf 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1,8 +1,11 @@ set('publisherMessaging', function (Publisher $publisher) { $container->set('publisherWebhooks', function (Publisher $publisher) { return $publisher; }, ['publisher']); +$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher( + $publisher, + new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher( + $publisher, + new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher( + $publisher, + new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME)) +), ['publisher']); $container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 63e58e92f7..6c8eef4d92 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -4,17 +4,17 @@ use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Databases\TransactionState; -use Appwrite\Event\Audit as AuditEvent; use Appwrite\Event\Build; -use Appwrite\Event\Certificate; +use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; +use Appwrite\Event\Migration; use Appwrite\Event\Realtime; -use Appwrite\Event\Screenshot; +use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; @@ -128,9 +128,6 @@ return function (Container $container): void { $container->set('queueForBuilds', function (Publisher $publisher) { return new Build($publisher); }, ['publisher']); - $container->set('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); - }, ['publisher']); $container->set('queueForDatabase', function (Publisher $publisher) { return new EventDatabase($publisher); }, ['publisher']); @@ -149,17 +146,18 @@ return function (Container $container): void { $container->set('usage', function () { return new UsageContext(); }, []); - $container->set('queueForAudits', function (Publisher $publisher) { - return new AuditEvent($publisher); - }, ['publisher']); + $container->set('auditContext', fn () => new AuditContext(), []); $container->set('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); $container->set('eventProcessor', function () { return new EventProcessor(); }, []); - $container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); + $container->set('queueForMigrations', function (Publisher $publisher) { + return new Migration($publisher); + }, ['publisher']); + $container->set('queueForStatsResources', function (Publisher $publisher) { + return new StatsResources($publisher); }, ['publisher']); $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index f893c84858..b513809a9b 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,16 +1,14 @@ set('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); - }, ['publisher']); - $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); @@ -323,10 +317,6 @@ return function (Container $container): void { return new Event($publisher); }, ['publisher']); - $container->set('queueForAudits', function (Publisher $publisher) { - return new Audit($publisher); - }, ['publisher']); - $container->set('queueForWebhooks', function (Publisher $publisher) { return new Webhook($publisher); }, ['publisher']); @@ -339,8 +329,8 @@ return function (Container $container): void { return new Realtime(); }, []); - $container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); + $container->set('queueForMigrations', function (Publisher $publisher) { + return new Migration($publisher); }, ['publisher']); $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) { diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php new file mode 100644 index 0000000000..3cfd785ff8 --- /dev/null +++ b/src/Appwrite/Event/Context/Audit.php @@ -0,0 +1,134 @@ +project = $project; + + return $this; + } + + public function getProject(): ?Document + { + return $this->project; + } + + public function setUser(Document $user): self + { + $this->user = $user; + + return $this; + } + + public function getUser(): ?Document + { + return $this->user; + } + + public function setMode(string $mode): self + { + $this->mode = $mode; + + return $this; + } + + public function getMode(): string + { + return $this->mode; + } + + public function setUserAgent(string $userAgent): self + { + $this->userAgent = $userAgent; + + return $this; + } + + public function getUserAgent(): string + { + return $this->userAgent; + } + + public function setIP(string $ip): self + { + $this->ip = $ip; + + return $this; + } + + public function getIP(): string + { + return $this->ip; + } + + public function setHostname(string $hostname): self + { + $this->hostname = $hostname; + + return $this; + } + + public function getHostname(): string + { + return $this->hostname; + } + + public function setEvent(string $event): self + { + $this->event = $event; + + return $this; + } + + public function getEvent(): string + { + return $this->event; + } + + public function setResource(string $resource): self + { + $this->resource = $resource; + + return $this; + } + + public function getResource(): string + { + return $this->resource; + } + + public function setPayload(array $payload): self + { + $this->payload = $payload; + + return $this; + } + + public function getPayload(): array + { + return $this->payload; + } +} diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php new file mode 100644 index 0000000000..febd96b072 --- /dev/null +++ b/src/Appwrite/Event/Message/Audit.php @@ -0,0 +1,55 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'user' => $this->user->getArrayCopy(), + 'payload' => $this->payload, + 'resource' => $this->resource, + 'mode' => $this->mode, + 'ip' => $this->ip, + 'userAgent' => $this->userAgent, + 'event' => $this->event, + 'hostname' => $this->hostname, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + user: new Document($data['user'] ?? []), + payload: $data['payload'] ?? [], + resource: $data['resource'] ?? '', + mode: $data['mode'] ?? '', + ip: $data['ip'] ?? '', + userAgent: $data['userAgent'] ?? '', + event: $data['event'] ?? '', + hostname: $data['hostname'] ?? '', + ); + } +} diff --git a/src/Appwrite/Event/Message/Certificate.php b/src/Appwrite/Event/Message/Certificate.php new file mode 100644 index 0000000000..a189bb8187 --- /dev/null +++ b/src/Appwrite/Event/Message/Certificate.php @@ -0,0 +1,43 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'domain' => $this->domain->getArrayCopy(), + 'skipRenewCheck' => $this->skipRenewCheck, + 'validationDomain' => $this->validationDomain, + 'action' => $this->action, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + domain: new Document($data['domain'] ?? []), + skipRenewCheck: $data['skipRenewCheck'] ?? false, + validationDomain: $data['validationDomain'] ?? null, + action: $data['action'] ?? \Appwrite\Event\Certificate::ACTION_GENERATION, + ); + } +} diff --git a/src/Appwrite/Event/Message/Screenshot.php b/src/Appwrite/Event/Message/Screenshot.php new file mode 100644 index 0000000000..05340fbda5 --- /dev/null +++ b/src/Appwrite/Event/Message/Screenshot.php @@ -0,0 +1,37 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'deploymentId' => $this->deploymentId, + 'platform' => $this->platform, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + deploymentId: $data['deploymentId'] ?? '', + platform: $data['platform'] ?? [], + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Audit.php b/src/Appwrite/Event/Publisher/Audit.php new file mode 100644 index 0000000000..daa9a01fce --- /dev/null +++ b/src/Appwrite/Event/Publisher/Audit.php @@ -0,0 +1,35 @@ +publish($this->queue, $message); + } catch (\Throwable $th) { + Console::error('[Audit] Failed to publish audit message: ' . $th->getMessage()); + + return false; + } + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Certificate.php b/src/Appwrite/Event/Publisher/Certificate.php new file mode 100644 index 0000000000..472fb0d701 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Certificate.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Screenshot.php b/src/Appwrite/Event/Publisher/Screenshot.php new file mode 100644 index 0000000000..2a0fa1e0f8 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Screenshot.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index c6c4a0b38c..41352c36f6 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -6,9 +6,9 @@ use Ahc\Jwt\JWT; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; -use Appwrite\Event\Screenshot; use Appwrite\Event\Webhook; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Usage\Context; @@ -58,7 +58,7 @@ class Builds extends Action ->inject('project') ->inject('dbForPlatform') ->inject('queueForEvents') - ->inject('queueForScreenshots') + ->inject('publisherForScreenshots') ->inject('queueForWebhooks') ->inject('queueForFunctions') ->inject('queueForRealtime') @@ -84,7 +84,7 @@ class Builds extends Action Document $project, Database $dbForPlatform, Event $queueForEvents, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, @@ -126,7 +126,7 @@ class Builds extends Action $deviceForFunctions, $deviceForSites, $deviceForFiles, - $queueForScreenshots, + $publisherForScreenshots, $queueForWebhooks, $queueForFunctions, $queueForRealtime, @@ -161,7 +161,7 @@ class Builds extends Action Device $deviceForFunctions, Device $deviceForSites, Device $deviceForFiles, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, @@ -1120,10 +1120,11 @@ class Builds extends Action /** Screenshot site */ if ($resource->getCollection() === 'sites') { - $queueForScreenshots - ->setDeploymentId($deployment->getId()) - ->setProject($project) - ->trigger(); + $publisherForScreenshots->enqueue(new \Appwrite\Event\Message\Screenshot( + project: $project, + deploymentId: $deployment->getId(), + platform: $platform, + )); Console::log('Site screenshot queued'); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 065fe477eb..423bf0bd41 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; +use Appwrite\Event\Message\Screenshot; use Appwrite\Event\Realtime; use Appwrite\Permission; use Appwrite\Role; @@ -62,9 +63,11 @@ class Screenshots extends Action throw new \Exception('Missing payload'); } + $screenshotMessage = Screenshot::fromArray($payload); + Console::log('Site screenshot started'); - $deploymentId = $payload['deploymentId'] ?? null; + $deploymentId = $screenshotMessage->deploymentId; $deployment = $dbForProject->getDocument('deployments', $deploymentId); if ($deployment->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php index e01e89641d..76c34a0a2a 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Audits; -use Appwrite\Event\Audit; +use Appwrite\Event\Publisher\Audit; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void { $threshold = (int) $threshold; - $size = $queueForAudits->getSize(); + $size = $publisherForAudits->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php index 6724f25094..82c45db172 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Certificates; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Certificate $queueForCertificates, Response $response): void + public function action(int|string $threshold, Certificate $publisherForCertificates, Response $response): void { $threshold = (int) $threshold; - $size = $queueForCertificates->getSize(); + $size = $publisherForCertificates->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 1f7cc0bf33..6d77cc6e16 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -2,19 +2,19 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed; -use Appwrite\Event\Audit; use Appwrite\Event\Build; -use Appwrite\Event\Certificate; use Appwrite\Event\Database; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; +use Appwrite\Event\Publisher\Audit; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Event\Publisher\Migration as MigrationPublisher; +use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Event\Screenshot; use Appwrite\Event\Webhook; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; @@ -75,17 +75,17 @@ class Get extends Base ->inject('response') ->inject('queueForDatabase') ->inject('queueForDeletes') - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('queueForMails') ->inject('queueForFunctions') ->inject('publisherForStatsResources') ->inject('publisherForUsage') ->inject('queueForWebhooks') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForBuilds') ->inject('queueForMessaging') ->inject('publisherForMigrations') - ->inject('queueForScreenshots') + ->inject('publisherForScreenshots') ->callback($this->action(...)); } @@ -95,32 +95,32 @@ class Get extends Base Response $response, Database $queueForDatabase, Delete $queueForDeletes, - Audit $queueForAudits, + Audit $publisherForAudits, Mail $queueForMails, Func $queueForFunctions, StatsResourcesPublisher $publisherForStatsResources, UsagePublisher $publisherForUsage, Webhook $queueForWebhooks, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Build $queueForBuilds, Messaging $queueForMessaging, MigrationPublisher $publisherForMigrations, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, ): void { $threshold = (int) $threshold; $queue = match ($name) { System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase, System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes, - System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits, + System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $publisherForAudits, System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails, System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions, System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $publisherForStatsResources, System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage, System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, - System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates, + System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates, System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, - System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $queueForScreenshots, + System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, }; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php index dd05aebc39..0a655662de 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Logs; -use Appwrite\Event\Audit; +use Appwrite\Event\Publisher\Audit; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void { $threshold = (int) $threshold; - $size = $queueForAudits->getSize(); + $size = $publisherForAudits->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index bfa62ef920..a6a3e44194 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\API; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -62,7 +62,7 @@ class Create extends Action ->param('domain', null, new ValidatorDomain(), 'Domain name.') ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('platform') @@ -70,7 +70,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) + public function action(string $domain, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -114,13 +114,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index a61ce80c4b..4a8bd4897e 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Function; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -66,7 +66,7 @@ class Create extends Action ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -132,13 +132,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 95c29f48e8..8a265ba5bb 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -69,7 +69,7 @@ class Create extends Action ->param('resourceType', '', new WhiteList(['site', 'function']), 'Type of parent resource.') ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -78,7 +78,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -136,13 +136,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index ba99cefb42..a9dfa93a49 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Site; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -66,7 +66,7 @@ class Create extends Action ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -132,13 +132,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php index 8a0d341132..9e81f6ff18 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Verification; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -56,7 +56,7 @@ class Update extends Action )) ->param('ruleId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Rule ID.', false, ['dbForProject']) ->inject('response') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('project') ->inject('dbForPlatform') @@ -67,7 +67,7 @@ class Update extends Action public function action( string $ruleId, Response $response, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Event $queueForEvents, Document $project, Database $dbForPlatform, @@ -110,12 +110,13 @@ class Update extends Action } // Issue a TLS certificate when DNS verification is successful - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->trigger(); + ]), + )); if (!empty($certificate)) { $rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', '')); diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index a7d16e0a52..f5502a5986 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use DateTime; use Swoole\Coroutine\Channel; use Swoole\Process; @@ -29,16 +29,16 @@ class Interval extends Action ->desc('Schedules tasks on regular intervals by publishing them to our queues') ->inject('dbForPlatform') ->inject('getProjectDB') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->callback($this->action(...)); } - public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): void + public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): void { Console::title('Interval V1'); Console::success(APP_NAME . ' interval process v1 has started'); - $timers = $this->runTasks($dbForPlatform, $getProjectDB, $queueForCertificates); + $timers = $this->runTasks($dbForPlatform, $getProjectDB, $publisherForCertificates); $chan = new Channel(1); Process::signal(SIGTERM, function () use ($chan) { @@ -52,16 +52,16 @@ class Interval extends Action } } - public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): array + public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): array { $timers = []; $tasks = $this->getTasks(); foreach ($tasks as $task) { - $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $queueForCertificates) { + $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $publisherForCertificates) { $taskName = $task['name']; Span::init("interval.{$taskName}"); try { - $task['callback']($dbForPlatform, $getProjectDB, $queueForCertificates); + $task['callback']($dbForPlatform, $getProjectDB, $publisherForCertificates); } catch (\Exception $e) { Span::error($e); } finally { @@ -80,15 +80,15 @@ class Interval extends Action return [ [ 'name' => 'domainVerification', - "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates) { - $this->verifyDomain($dbForPlatform, $queueForCertificates); + "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates) { + $this->verifyDomain($dbForPlatform, $publisherForCertificates); }, 'interval' => $intervalDomainVerification * 1000, ] ]; } - private function verifyDomain(Database $dbForPlatform, Certificate $queueForCertificates): void + private function verifyDomain(Database $dbForPlatform, Certificate $publisherForCertificates): void { $time = DatabaseDateTime::now(); $fromTime = new DateTime('-3 days'); // Max 3 days old @@ -115,13 +115,17 @@ class Interval extends Action foreach ($rules as $rule) { try { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_DOMAIN_VERIFICATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION, + )); $processed++; } catch (\Throwable $th) { $failed++; diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index c821435786..fe803f1292 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete; +use Appwrite\Event\Publisher\Certificate; use DateInterval; use DateTime; use Utopia\Console; @@ -29,12 +29,12 @@ class Maintenance extends Action ->param('type', 'loop', new WhiteList(['loop', 'trigger']), 'How to run task. "loop" is meant for container entrypoint, and "trigger" for manual execution.') ->inject('dbForPlatform') ->inject('console') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForDeletes') ->callback($this->action(...)); } - public function action(string $type, Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void + public function action(string $type, Database $dbForPlatform, Document $console, Certificate $publisherForCertificates, Delete $queueForDeletes): void { Console::title('Maintenance V1'); Console::success(APP_NAME . ' maintenance process v1 has started'); @@ -59,7 +59,7 @@ class Maintenance extends Action $delay = $next->getTimestamp() - $now->getTimestamp(); } - $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) { + $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $publisherForCertificates) { $time = DatabaseDateTime::now(); Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds"); @@ -92,7 +92,7 @@ class Maintenance extends Action ->trigger(); $this->notifyDeleteConnections($queueForDeletes); - $this->renewCertificates($dbForPlatform, $queueForCertificates); + $this->renewCertificates($dbForPlatform, $publisherForCertificates); $this->notifyDeleteCache($cacheRetention, $queueForDeletes); $this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes); $this->notifyDeleteCSVExports($queueForDeletes); @@ -124,7 +124,7 @@ class Maintenance extends Action ->trigger(); } - private function renewCertificates(Database $dbForPlatform, Certificate $queueForCertificate): void + private function renewCertificates(Database $dbForPlatform, Certificate $publisherForCertificate): void { $time = DatabaseDateTime::now(); @@ -158,13 +158,17 @@ class Maintenance extends Action continue; } - $queueForCertificate - ->setDomain(new Document([ + $publisherForCertificate->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } } diff --git a/src/Appwrite/Platform/Tasks/SSL.php b/src/Appwrite/Platform/Tasks/SSL.php index ef8283f168..cb33836a99 100644 --- a/src/Appwrite/Platform/Tasks/SSL.php +++ b/src/Appwrite/Platform/Tasks/SSL.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\Document; @@ -29,11 +29,11 @@ class SSL extends Action ->param('skip-check', 'true', new Boolean(true), 'If DNS and renew check should be skipped. Defaults to true, and when true, all jobs will result in certificate generation attempt.', true) ->inject('console') ->inject('dbForPlatform') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->callback($this->action(...)); } - public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $queueForCertificates): void + public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates): void { $domain = new Domain(!empty($domain) ? $domain : ''); if (!$domain->isKnown() || $domain->isTest()) { @@ -98,12 +98,13 @@ class SSL extends Action Console::info('Updated existing rule ' . $rule->getId() . ' for domain: ' . $domain->get()); } - $queueForCertificates - ->setDomain(new Document([ - 'domain' => $domain->get() - ])) - ->setSkipRenewCheck($skipCheck) - ->trigger(); + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $console, + domain: new Document([ + 'domain' => $domain->get(), + ]), + skipRenewCheck: $skipCheck, + )); Console::success('Scheduled a job to issue a TLS certificate for domain: ' . $domain->get()); } diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index 6bcc85bc36..e5a7950945 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Event\Message\Audit; use Exception; use Throwable; use Utopia\Console; @@ -40,7 +41,6 @@ class Audits extends Action $this ->desc('Audits worker') ->inject('message') - ->inject('project') ->inject('getAudit') ->callback($this->action(...)); @@ -50,14 +50,13 @@ class Audits extends Action /** * @param Message $message - * @param Document $project * @param callable(Document): \Utopia\Audit\Audit $getAudit * @return Commit|NoCommit * @throws Throwable * @throws \Utopia\Database\Exception * @throws Structure */ - public function action(Message $message, Document $project, callable $getAudit): Commit|NoCommit + public function action(Message $message, callable $getAudit): Commit|NoCommit { $payload = $message->getPayload() ?? []; @@ -65,19 +64,21 @@ class Audits extends Action throw new Exception('Missing payload'); } + $auditMessage = Audit::fromArray($payload); + Console::info('Aggregating audit logs'); - $event = $payload['event'] ?? ''; + $event = $auditMessage->event; $auditPayload = ''; - if ($project->getId() === 'console') { - $auditPayload = $payload['payload'] ?? ''; + if ($auditMessage->project->getId() === 'console') { + $auditPayload = $auditMessage->payload; } - $mode = $payload['mode'] ?? ''; - $resource = $payload['resource'] ?? ''; - $userAgent = $payload['userAgent'] ?? ''; - $ip = $payload['ip'] ?? ''; - $user = new Document($payload['user'] ?? []); + $mode = $auditMessage->mode; + $resource = $auditMessage->resource; + $userAgent = $auditMessage->userAgent; + $ip = $auditMessage->ip; + $user = $auditMessage->user; $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $actorUserId = $impersonatorUserId ?: $user->getId(); @@ -126,14 +127,14 @@ class Audits extends Action ]; } - if (isset($this->logs[$project->getSequence()])) { - $this->logs[$project->getSequence()]['logs'][] = $eventData; + if (isset($this->logs[$auditMessage->project->getSequence()])) { + $this->logs[$auditMessage->project->getSequence()]['logs'][] = $eventData; } else { - $this->logs[$project->getSequence()] = [ + $this->logs[$auditMessage->project->getSequence()] = [ 'project' => new Document([ - '$id' => $project->getId(), - '$sequence' => $project->getSequence(), - 'database' => $project->getAttribute('database'), + '$id' => $auditMessage->project->getId(), + '$sequence' => $auditMessage->project->getSequence(), + 'database' => $auditMessage->project->getAttribute('database'), ]), 'logs' => [$eventData] ]; diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 73509819a9..34234971d9 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -3,10 +3,10 @@ namespace Appwrite\Platform\Workers; use Appwrite\Certificates\Adapter as CertificatesAdapter; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception as AppwriteException; @@ -55,7 +55,7 @@ class Certificates extends Action ->inject('queueForWebhooks') ->inject('queueForFunctions') ->inject('queueForRealtime') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('log') ->inject('certificates') ->inject('plan') @@ -71,7 +71,7 @@ class Certificates extends Action * @param Webhook $queueForWebhooks * @param Func $queueForFunctions * @param Realtime $queueForRealtime - * @param Certificate $queueForCertificates + * @param Certificate $publisherForCertificates * @param Log $log * @param CertificatesAdapter $certificates * @param array $plan @@ -88,7 +88,7 @@ class Certificates extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Log $log, CertificatesAdapter $certificates, array $plan, @@ -100,21 +100,22 @@ class Certificates extends Action throw new Exception('Missing payload'); } - $document = new Document($payload['domain'] ?? []); + $certificateMessage = \Appwrite\Event\Message\Certificate::fromArray($payload); + $document = $certificateMessage->domain; $domain = new Domain($document->getAttribute('domain', '')); $domainType = $document->getAttribute('domainType'); - $skipRenewCheck = $payload['skipRenewCheck'] ?? false; - $validationDomain = $payload['validationDomain'] ?? null; - $action = $payload['action'] ?? Certificate::ACTION_GENERATION; + $skipRenewCheck = $certificateMessage->skipRenewCheck; + $validationDomain = $certificateMessage->validationDomain; + $action = $certificateMessage->action; $log->addTag('domain', $domain->get()); switch ($action) { - case Certificate::ACTION_DOMAIN_VERIFICATION: - $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $queueForCertificates, $log, $authorization, $validationDomain); + case \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION: + $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $publisherForCertificates, $log, $authorization, $validationDomain); break; - case Certificate::ACTION_GENERATION: + case \Appwrite\Event\Certificate::ACTION_GENERATION: $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain); break; @@ -130,7 +131,7 @@ class Certificates extends Action * @param Webhook $queueForWebhooks * @param Func $queueForFunctions * @param Realtime $queueForRealtime - * @param Certificate $queueForCertificates + * @param Certificate $publisherForCertificates * @param Log $log * @param ValidatorAuthorization $authorization * @param string|null $validationDomain @@ -146,7 +147,7 @@ class Certificates extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Log $log, ValidatorAuthorization $authorization, ?string $validationDomain = null @@ -188,13 +189,17 @@ class Certificates extends Action // Issue a TLS certificate when domain is verified if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); Console::success('Certificate generation triggered successfully.'); } From 0de26be6e6c2d6310f0b6ee00442bef0114438e5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 16:40:29 +0530 Subject: [PATCH 09/96] chore: address review feedback --- src/Appwrite/Event/Message/Screenshot.php | 3 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 1 - 2 files changed, 4 deletions(-) diff --git a/src/Appwrite/Event/Message/Screenshot.php b/src/Appwrite/Event/Message/Screenshot.php index 05340fbda5..a06cdfbfc0 100644 --- a/src/Appwrite/Event/Message/Screenshot.php +++ b/src/Appwrite/Event/Message/Screenshot.php @@ -9,7 +9,6 @@ final class Screenshot extends Base public function __construct( public readonly Document $project, public readonly string $deploymentId, - public readonly array $platform = [], ) { } @@ -22,7 +21,6 @@ final class Screenshot extends Base 'database' => $this->project->getAttribute('database', ''), ], 'deploymentId' => $this->deploymentId, - 'platform' => $this->platform, ]; } @@ -31,7 +29,6 @@ final class Screenshot extends Base return new self( project: new Document($data['project'] ?? []), deploymentId: $data['deploymentId'] ?? '', - platform: $data['platform'] ?? [], ); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 41352c36f6..0071b03d2d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -1123,7 +1123,6 @@ class Builds extends Action $publisherForScreenshots->enqueue(new \Appwrite\Event\Message\Screenshot( project: $project, deploymentId: $deployment->getId(), - platform: $platform, )); Console::log('Site screenshot queued'); From 96fe989f6d294482b98fc97f031c216a94dbb11b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 10 Apr 2026 17:15:31 +0530 Subject: [PATCH 10/96] update composer dependencies and remove obsolete log classes --- composer.json | 2 +- composer.lock | 27 ++++--- .../Collections/Documents/Logs/XList.php | 59 -------------- .../DocumentsDB/Collections/Logs/XList.php | 58 -------------- .../Collections/Documents/Logs/XList.php | 59 -------------- .../Http/VectorsDB/Collections/Logs/XList.php | 57 ------------- .../e2e/Services/Databases/DatabasesBase.php | 79 +++++++++++++++++++ 7 files changed, 98 insertions(+), 243 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php delete mode 100644 src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php delete mode 100644 src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php delete mode 100644 src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php diff --git a/composer.json b/composer.json index 4ad1ae6120..b502fa191e 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", - "utopia-php/database": "5.*", + "utopia-php/database": "dev-datetime-exception as 5.21.0", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index 164b3a036f..777c02076b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4fb974e9843f6104e40396e7cad4a833", + "content-hash": "cf3f6bf217746bbfb9d5a5a8c3295eef", "packages": [ { "name": "adhocore/jwt", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.19", + "version": "dev-datetime-exception", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" + "reference": "615a530e6434e74742b6b12dabee5993ba8575fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", - "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", + "url": "https://api.github.com/repos/utopia-php/database/zipball/615a530e6434e74742b6b12dabee5993ba8575fd", + "reference": "615a530e6434e74742b6b12dabee5993ba8575fd", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.19" + "source": "https://github.com/utopia-php/database/tree/datetime-exception" }, - "time": "2026-03-31T15:52:08+00:00" + "time": "2026-04-10T11:10:59+00:00" }, { "name": "utopia-php/detector", @@ -8426,9 +8426,18 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-datetime-exception", + "alias": "5.21.0", + "alias_normalized": "5.21.0.0" + } + ], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php deleted file mode 100644 index cc7fe41555..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php +++ /dev/null @@ -1,59 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') - ->desc('List document logs') - ->groups(['api', 'database']) - ->label('scope', 'documents.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'documentsDB', - group: 'logs', - name: 'listDocumentLogs', - description: '/docs/references/documentsdb/get-document-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON, - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('documentId', '', new UID(), 'Document ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php deleted file mode 100644 index 51695ea165..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php +++ /dev/null @@ -1,58 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/logs') - ->desc('List collection logs') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'documentsDB', - group: $this->getSdkGroup(), - name: 'listCollectionLogs', - description: '/docs/references/documentsdb/get-collection-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) - ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php deleted file mode 100644 index dea9d30119..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php +++ /dev/null @@ -1,59 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') - ->desc('List document logs') - ->groups(['api', 'database']) - ->label('scope', 'documents.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'vectorsDB', - group: 'logs', - name: 'listDocumentLogs', - description: '/docs/references/vectorsdb/get-document-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON, - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('documentId', '', new UID(), 'Document ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php deleted file mode 100644 index cd0e45eb47..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php +++ /dev/null @@ -1,57 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/logs') - ->desc('List collection logs') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'vectorsDB', - group: $this->getSdkGroup(), - name: 'listCollectionLogs', - description: '/docs/references/vectorsdb/get-collection-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 5b2a401fd3..ed8f6ead11 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -11618,4 +11618,83 @@ trait DatabasesBase $this->assertEquals('draft', $refetched['body']['status']); } } + + /** + * API keys may set $createdAt / $updatedAt; invalid strings must return 400, not 500. + * Assertions are HTTP status codes only (no error body matching). + */ + public function testInvalidDate(): void + { + $data = $this->setupAttributes(); + $databaseId = $data['databaseId']; + $invalidDatetime = '1dfs:12:55+sdf:00'; + $validUpdatedAt = '2024-01-01T00:00:00Z'; + + $apiKeyHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + + $documentPayload = [ + 'title' => 'Captain America', + 'releaseYear' => 1944, + 'actors' => [ + 'Chris Evans', + 'Samuel Jackson', + ], + ]; + $permissions = [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ]; + + $invalidCreate = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => \array_merge($documentPayload, ['$updatedAt' => $invalidDatetime]), + 'permissions' => $permissions, + ]); + $this->assertEquals(400, $invalidCreate['headers']['status-code']); + + $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => $documentPayload, + 'permissions' => $permissions, + ]); + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + $this->assertNotEmpty($documentId); + + $invalidPatch = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders, + [ + 'data' => [ + '$updatedAt' => $invalidDatetime, + ], + ] + ); + $this->assertEquals(400, $invalidPatch['headers']['status-code']); + + $updated = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders, + [ + 'data' => [ + '$updatedAt' => $validUpdatedAt, + ], + ] + ); + $this->assertEquals(200, $updated['headers']['status-code']); + + $refetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders + ); + $this->assertEquals(200, $refetched['headers']['status-code']); + } } From b622b092a804582ed10dae49f27f98b9766e844e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 10 Apr 2026 17:22:22 +0530 Subject: [PATCH 11/96] updated --- .../Modules/Databases/Services/Registry/DocumentsDB.php | 4 ---- .../Modules/Databases/Services/Registry/VectorsDB.php | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php index a1e3538cac..5d41ed3e2b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php @@ -12,7 +12,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\B use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Create as CreateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Delete as DeleteRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Get as GetRow; -use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Logs\XList as ListRowLogs; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Update as UpdateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Upsert as UpsertRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\XList as ListRows; @@ -21,7 +20,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Cre use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Delete as DeleteColumnIndex; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Get as GetColumnIndex; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\XList as ListColumnIndexes; -use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Logs\XList as ListTableLogs; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Update as UpdateTable; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Usage\Get as GetTableUsage; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\XList as ListTables; @@ -69,7 +67,6 @@ class DocumentsDB extends Base $service->addAction(UpdateTable::getName(), new UpdateTable()); $service->addAction(DeleteTable::getName(), new DeleteTable()); $service->addAction(ListTables::getName(), new ListTables()); - $service->addAction(ListTableLogs::getName(), new ListTableLogs()); $service->addAction(GetTableUsage::getName(), new GetTableUsage()); } @@ -92,7 +89,6 @@ class DocumentsDB extends Base $service->addAction(DeleteRow::getName(), new DeleteRow()); $service->addAction(DeleteRows::getName(), new DeleteRows()); $service->addAction(ListRows::getName(), new ListRows()); - $service->addAction(ListRowLogs::getName(), new ListRowLogs()); $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn()); $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn()); } diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php index 5d12b14b1a..fe96d51d20 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php @@ -10,7 +10,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Bul use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Create as CreateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Delete as DeleteDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Get as GetDocument; -use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Logs\XList as ListDocumentLogs; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Update as UpdateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Upsert as UpsertDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\XList as ListDocuments; @@ -19,7 +18,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Creat use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Delete as DeleteIndex; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Get as GetIndex; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\XList as ListIndexes; -use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Logs\XList as ListCollectionLogs; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Update as UpdateCollection; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Usage\Get as GetCollectionUsage; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\XList as ListCollections; @@ -69,7 +67,6 @@ class VectorsDB extends Base $service->addAction(UpdateCollection::getName(), new UpdateCollection()); $service->addAction(DeleteCollection::getName(), new DeleteCollection()); $service->addAction(ListCollections::getName(), new ListCollections()); - $service->addAction(ListCollectionLogs::getName(), new ListCollectionLogs()); $service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage()); } @@ -92,7 +89,6 @@ class VectorsDB extends Base $service->addAction(UpdateDocuments::getName(), new UpdateDocuments()); $service->addAction(UpsertDocuments::getName(), new UpsertDocuments()); $service->addAction(DeleteDocuments::getName(), new DeleteDocuments()); - $service->addAction(ListDocumentLogs::getName(), new ListDocumentLogs()); } private function registerTransactionActions(Service $service): void From 0cce480592739e33d210224a5da245d6d7ebc194 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 10 Apr 2026 17:23:43 +0530 Subject: [PATCH 12/96] typo --- tests/e2e/Services/Databases/DatabasesBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index ed8f6ead11..f5f1d1864c 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -11539,7 +11539,7 @@ trait DatabasesBase $this->assertEquals('Product B', $rows['body'][$this->getRecordResource()][0]['name']); $this->assertEquals(139.99, $rows['body'][$this->getRecordResource()][0]['price']); } - public function testDocumentWithEmptyPaylod(): void + public function testDocumentWithEmptyPayload(): void { $data = $this->setupCollection(); $databaseId = $data['databaseId']; From ec5472f1edc0bfdffc2b2cfbbf2e7394d2ee54d9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 11 Apr 2026 08:57:06 +0530 Subject: [PATCH 13/96] chore: remove unrelated queue resources --- app/init/resources/request.php | 8 -------- app/init/worker/message.php | 5 ----- 2 files changed, 13 deletions(-) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 6c8eef4d92..3f6196c460 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -12,9 +12,7 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; use Appwrite\Event\Realtime; -use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; @@ -153,12 +151,6 @@ return function (Container $container): void { $container->set('eventProcessor', function () { return new EventProcessor(); }, []); - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('queueForStatsResources', function (Publisher $publisher) { - return new StatsResources($publisher); - }, ['publisher']); $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); $database = new Database($adapter, $cache); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index b513809a9b..c505d4cb3a 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -7,7 +7,6 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; use Appwrite\Usage\Context; @@ -329,10 +328,6 @@ return function (Container $container): void { return new Realtime(); }, []); - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) { return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); }, ['project', 'telemetry']); From 5ecd15a5f5baafe7d9944e8092fd7bfe7bf66a4d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 11 Apr 2026 09:07:51 +0530 Subject: [PATCH 14/96] fix: register certificate publisher in cli --- app/cli.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/cli.php b/app/cli.php index ee0b4a6103..a6267fa341 100644 --- a/app/cli.php +++ b/app/cli.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/init.php'; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Publisher\Certificate as CertificatePublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Platform\Appwrite; @@ -252,6 +253,10 @@ $container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePubli $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); +$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher( + $publisher, + new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME)) +), ['publisher']); $container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher( $publisher, new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) From 18b1c344e662eea02decaf4dfee45c9a2e34e8c8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 13 Apr 2026 06:34:58 +0000 Subject: [PATCH 15/96] fix: serialize batched GraphQL queries for coroutine safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Swoole coroutine hooks are enabled (SWOOLE_HOOK_ALL), batched GraphQL queries execute in parallel coroutines that share a single Response object. Concurrent coroutines interleave writes to the shared response payload, causing data mixing between queries. Cloning the response is not viable because cookies/headers written by the action (e.g. session tokens) must reach the real HTTP response. Instead, serialize the critical section (execute → getPayload) using a Swoole Channel as a coroutine-safe mutex. This ensures only one batched query writes to the Response at a time while preserving cookie/header propagation. The lock is released before resolve/reject callbacks so downstream processing remains concurrent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Appwrite/GraphQL/Resolvers.php | 44 ++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 65f8a64d68..8e1da6d493 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,6 +6,7 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; +use Swoole\Coroutine\Channel; use Utopia\Http\Exception; use Utopia\Http\Http; use Utopia\Http\Route; @@ -13,6 +14,29 @@ use Utopia\System\System; class Resolvers { + /** + * Per-request channel used to serialize batched query execution so + * concurrent coroutines don't interleave writes on the shared Response. + */ + private static ?Channel $lock = null; + + /** + * Acquire a coroutine-safe lock for the current request. + * Creates the channel lazily and pushes a token; the channel + * capacity of 1 ensures only one resolve() runs at a time. + */ + private static function acquireLock(): void + { + if (self::$lock === null) { + self::$lock = new Channel(1); + } + self::$lock->push(true); + } + + private static function releaseLock(): void + { + self::$lock?->pop(); + } /** * Create a resolver for a given API {@see Route}. * @@ -261,30 +285,38 @@ class Resolvers $request = clone $request; $utopia->setResource('request', static fn () => $request); - $response->setContentType(Response::CONTENT_TYPE_NULL); - $response->clearSent(); + // Serialize execution: when Swoole coroutine hooks are active, + // batched queries run in parallel coroutines sharing one Response. + // The lock ensures only one query writes to the Response at a time. + self::acquireLock(); try { + $response->setContentType(Response::CONTENT_TYPE_NULL); + $response->clearSent(); + $route = $utopia->match($request, fresh: true); $utopia->execute($route, $request, $response); + + $payload = $response->getPayload(); + $statusCode = $response->getStatusCode(); } catch (\Throwable $e) { + self::releaseLock(); if ($beforeReject) { $e = $beforeReject($e); } $reject($e); return; } + self::releaseLock(); - $payload = $response->getPayload(); - - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) { + if ($statusCode < 200 || $statusCode >= 400) { if ($beforeReject) { $payload = $beforeReject($payload); } $reject(new GQLException( message: $payload['message'], - code: $response->getStatusCode() + code: $statusCode )); return; } From 2807d6cd9a60240a3c5a3bf93a4fa172ee658894 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:57:27 +0100 Subject: [PATCH 16/96] feat: increase default build timeout to 45 minutes Raises _APP_COMPUTE_BUILD_TIMEOUT default from 900s (15 min) to 2700s (45 min) to support longer-running builds. Co-Authored-By: Claude Sonnet 4.6 --- app/config/variables.php | 8 ++++---- src/Appwrite/Vcs/Comment.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 7a3ed13049..c834656ff4 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -872,18 +872,18 @@ return [ ], [ 'name' => '_APP_FUNCTIONS_BUILD_TIMEOUT', - 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 900 seconds.', + 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 2700 seconds.', 'introduction' => '0.13.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' ], [ 'name' => '_APP_COMPUTE_BUILD_TIMEOUT', - 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 900 seconds.', + 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 2700 seconds.', 'introduction' => '1.7.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index e6d6996748..e9fa460bb4 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -22,7 +22,7 @@ class Comment 'Every Git commit and branch gets its own deployment URL automatically', 'Custom domains work with both CNAME for subdomains and NS records for apex domains', 'HTTPS and SSL certificates are handled automatically for all your Sites', - 'Functions can run for up to 15 minutes before timing out', + 'Functions can run for up to 45 minutes before timing out', 'Schedule functions to run as often as every minute with cron expressions', 'Environment variables can be scoped per function or shared across your project', 'Function scopes give you fine-grained control over API permissions', From df5ccc10ad3f0f9edc3d89ad9334c753213c63a1 Mon Sep 17 00:00:00 2001 From: "Luke B. Silver" <22452787+loks0n@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:48:20 +0100 Subject: [PATCH 17/96] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/Appwrite/Vcs/Comment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index e9fa460bb4..148b29c1d1 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -22,7 +22,7 @@ class Comment 'Every Git commit and branch gets its own deployment URL automatically', 'Custom domains work with both CNAME for subdomains and NS records for apex domains', 'HTTPS and SSL certificates are handled automatically for all your Sites', - 'Functions can run for up to 45 minutes before timing out', + 'Function builds can take up to 45 minutes before timing out', 'Schedule functions to run as often as every minute with cron expressions', 'Environment variables can be scoped per function or shared across your project', 'Function scopes give you fine-grained control over API permissions', From 6bc2168e29664d7fcc1e522a12f7002a4af954f0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 16:34:27 +0530 Subject: [PATCH 18/96] fix: isolate graphql resolver request state --- src/Appwrite/GraphQL/Resolvers.php | 79 ++++++++++++++++-------------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 8e1da6d493..8d31991d09 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,7 +6,7 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; -use Swoole\Coroutine\Channel; +use Utopia\DI\Container; use Utopia\Http\Exception; use Utopia\Http\Http; use Utopia\Http\Route; @@ -15,27 +15,37 @@ use Utopia\System\System; class Resolvers { /** - * Per-request channel used to serialize batched query execution so - * concurrent coroutines don't interleave writes on the shared Response. + * Clone the shared GraphQL request before a resolver mutates it. */ - private static ?Channel $lock = null; - - /** - * Acquire a coroutine-safe lock for the current request. - * Creates the channel lazily and pushes a token; the channel - * capacity of 1 ensures only one resolve() runs at a time. - */ - private static function acquireLock(): void + private static function createResolverRequest(Http $utopia): Request { - if (self::$lock === null) { - self::$lock = new Channel(1); - } - self::$lock->push(true); + /** @var Request $request */ + $request = clone $utopia->getResource('request'); + + return $request; } - private static function releaseLock(): void + /** + * Clone the shared GraphQL response so each resolver writes into an + * isolated payload/status buffer. + */ + private static function createResolverResponse(Http $utopia): Response { - self::$lock?->pop(); + /** @var Response $response */ + $response = clone $utopia->getResource('response'); + + return $response; + } + + /** + * Get the current coroutine's request container. + */ + private static function getResolverContainer(Http $utopia): Container + { + /** @var callable(): Container $getContainer */ + $getContainer = $utopia->getResource('container'); + + return $getContainer(); } /** * Create a resolver for a given API {@see Route}. @@ -51,8 +61,8 @@ class Resolvers return static fn ($type, $args, $context, $info) => new Swoole( function (callable $resolve, callable $reject) use ($utopia, $route, $args) { $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + $request = self::createResolverRequest($utopia); + $response = self::createResolverResponse($utopia); $path = $route->getPath(); foreach ($args as $key => $value) { @@ -118,8 +128,8 @@ class Resolvers return static fn ($type, $args, $context, $info) => new Swoole( function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + $request = self::createResolverRequest($utopia); + $response = self::createResolverResponse($utopia); $request->setMethod('GET'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -149,8 +159,8 @@ class Resolvers return static fn ($type, $args, $context, $info) => new Swoole( function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + $request = self::createResolverRequest($utopia); + $response = self::createResolverResponse($utopia); $request->setMethod('GET'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -185,8 +195,8 @@ class Resolvers return static fn ($type, $args, $context, $info) => new Swoole( function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + $request = self::createResolverRequest($utopia); + $response = self::createResolverResponse($utopia); $request->setMethod('POST'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -217,8 +227,8 @@ class Resolvers return static fn ($type, $args, $context, $info) => new Swoole( function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + $request = self::createResolverRequest($utopia); + $response = self::createResolverResponse($utopia); $request->setMethod('PATCH'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -247,8 +257,8 @@ class Resolvers return static fn ($type, $args, $context, $info) => new Swoole( function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + $request = self::createResolverRequest($utopia); + $response = self::createResolverResponse($utopia); $request->setMethod('DELETE'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -283,13 +293,10 @@ class Resolvers $request->removeHeader('content-type'); } - $request = clone $request; - $utopia->setResource('request', static fn () => $request); + $container = self::getResolverContainer($utopia); + $container->set('request', static fn () => $request); + $container->set('response', static fn () => $response); - // Serialize execution: when Swoole coroutine hooks are active, - // batched queries run in parallel coroutines sharing one Response. - // The lock ensures only one query writes to the Response at a time. - self::acquireLock(); try { $response->setContentType(Response::CONTENT_TYPE_NULL); $response->clearSent(); @@ -301,14 +308,12 @@ class Resolvers $payload = $response->getPayload(); $statusCode = $response->getStatusCode(); } catch (\Throwable $e) { - self::releaseLock(); if ($beforeReject) { $e = $beforeReject($e); } $reject($e); return; } - self::releaseLock(); if ($statusCode < 200 || $statusCode >= 400) { if ($beforeReject) { From 70a75c2e7ba23d9ee0a0651f7b74c09e37afedb3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 16:47:33 +0530 Subject: [PATCH 19/96] fix: scope graphql resolver lock to request --- src/Appwrite/GraphQL/Resolvers.php | 200 +++++++++++++++++++---------- 1 file changed, 131 insertions(+), 69 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 8d31991d09..9e409b39ae 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,6 +6,9 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; +use stdClass; +use Swoole\Coroutine; +use Swoole\Coroutine\Channel; use Utopia\DI\Container; use Utopia\Http\Exception; use Utopia\Http\Http; @@ -15,30 +18,7 @@ use Utopia\System\System; class Resolvers { /** - * Clone the shared GraphQL request before a resolver mutates it. - */ - private static function createResolverRequest(Http $utopia): Request - { - /** @var Request $request */ - $request = clone $utopia->getResource('request'); - - return $request; - } - - /** - * Clone the shared GraphQL response so each resolver writes into an - * isolated payload/status buffer. - */ - private static function createResolverResponse(Http $utopia): Response - { - /** @var Response $response */ - $response = clone $utopia->getResource('response'); - - return $response; - } - - /** - * Get the current coroutine's request container. + * Get the current request container. */ private static function getResolverContainer(Http $utopia): Container { @@ -47,6 +27,72 @@ class Resolvers return $getContainer(); } + + /** + * Get the request-scoped lock shared by GraphQL resolver coroutines + * for the current HTTP request. + * + * @return stdClass{channel: Channel, owner: int|null, depth: int} + */ + private static function getLock(Http $utopia): stdClass + { + $container = self::getResolverContainer($utopia); + + if (!$container->has('graphql:lock')) { + $lock = new stdClass(); + $lock->channel = new Channel(1); + $lock->owner = null; + $lock->depth = 0; + + $container->set('graphql:lock', static fn () => $lock); + } + + /** @var stdClass{channel: Channel, owner: int|null, depth: int} $lock */ + $lock = $container->get('graphql:lock'); + + return $lock; + } + + /** + * Acquire the request-scoped resolver lock. Re-entering from the + * same coroutine only increments depth to avoid self-deadlock. + * + * @param stdClass{channel: Channel, owner: int|null, depth: int} $lock + */ + private static function acquireLock(stdClass $lock): void + { + $cid = Coroutine::getCid(); + + if ($lock->owner === $cid) { + $lock->depth++; + return; + } + + $lock->channel->push(true); + $lock->owner = $cid; + $lock->depth = 1; + } + + /** + * Release the request-scoped resolver lock. + * + * @param stdClass{channel: Channel, owner: int|null, depth: int} $lock + */ + private static function releaseLock(stdClass $lock): void + { + if ($lock->owner !== Coroutine::getCid()) { + return; + } + + $lock->depth--; + + if ($lock->depth > 0) { + return; + } + + $lock->owner = null; + $lock->channel->pop(); + } /** * Create a resolver for a given API {@see Route}. * @@ -58,11 +104,13 @@ class Resolvers Http $utopia, ?Route $route, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $route, $args) { + return static fn ($type, $args, $context, $info) => (function () use ($utopia, $route, $args) { + $lock = self::getLock($utopia); + + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args, $lock) { $utopia = $utopia->getResource('utopia:graphql'); - $request = self::createResolverRequest($utopia); - $response = self::createResolverResponse($utopia); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $path = $route->getPath(); foreach ($args as $key => $value) { @@ -83,9 +131,9 @@ class Resolvers break; } - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + }); + })(); } /** @@ -125,18 +173,20 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { + $lock = self::getLock($utopia); + + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args, $lock) { $utopia = $utopia->getResource('utopia:graphql'); - $request = self::createResolverRequest($utopia); - $response = self::createResolverResponse($utopia); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('GET'); $request->setURI($url($databaseId, $collectionId, $args)); - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + }); + })(); } /** @@ -156,11 +206,13 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $lock = self::getLock($utopia); + + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { $utopia = $utopia->getResource('utopia:graphql'); - $request = self::createResolverRequest($utopia); - $response = self::createResolverResponse($utopia); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('GET'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -170,9 +222,9 @@ class Resolvers return $payload['documents']; }; - self::resolve($utopia, $request, $response, $resolve, $reject, $beforeResolve); - } - ); + self::resolve($utopia, $request, $response, $lock, $resolve, $reject, $beforeResolve); + }); + })(); } /** @@ -192,19 +244,21 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $lock = self::getLock($utopia); + + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { $utopia = $utopia->getResource('utopia:graphql'); - $request = self::createResolverRequest($utopia); - $response = self::createResolverResponse($utopia); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('POST'); $request->setURI($url($databaseId, $collectionId, $args)); $request->setPayload($params($databaseId, $collectionId, $args)); - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + }); + })(); } /** @@ -224,19 +278,21 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $lock = self::getLock($utopia); + + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { $utopia = $utopia->getResource('utopia:graphql'); - $request = self::createResolverRequest($utopia); - $response = self::createResolverResponse($utopia); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('PATCH'); $request->setURI($url($databaseId, $collectionId, $args)); $request->setPayload($params($databaseId, $collectionId, $args)); - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + }); + })(); } /** @@ -254,24 +310,27 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { + $lock = self::getLock($utopia); + + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args, $lock) { $utopia = $utopia->getResource('utopia:graphql'); - $request = self::createResolverRequest($utopia); - $response = self::createResolverResponse($utopia); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('DELETE'); $request->setURI($url($databaseId, $collectionId, $args)); - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + }); + })(); } /** * @param Http $utopia * @param Request $request * @param Response $response + * @param stdClass{channel: Channel, owner: int|null, depth: int} $lock * @param callable $resolve * @param callable $reject * @param callable|null $beforeResolve @@ -283,6 +342,7 @@ class Resolvers Http $utopia, Request $request, Response $response, + stdClass $lock, callable $resolve, callable $reject, ?callable $beforeResolve = null, @@ -293,10 +353,10 @@ class Resolvers $request->removeHeader('content-type'); } - $container = self::getResolverContainer($utopia); - $container->set('request', static fn () => $request); - $container->set('response', static fn () => $response); + $request = clone $request; + $utopia->setResource('request', static fn () => $request); + self::acquireLock($lock); try { $response->setContentType(Response::CONTENT_TYPE_NULL); $response->clearSent(); @@ -308,12 +368,14 @@ class Resolvers $payload = $response->getPayload(); $statusCode = $response->getStatusCode(); } catch (\Throwable $e) { + self::releaseLock($lock); if ($beforeReject) { $e = $beforeReject($e); } $reject($e); return; } + self::releaseLock($lock); if ($statusCode < 200 || $statusCode >= 400) { if ($beforeReject) { From fc0fd2f6ac0f0ff8af9bf741b1a53b89c6e214f6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 17:10:38 +0530 Subject: [PATCH 20/96] fix: scope graphql resources to resolver coroutine --- src/Appwrite/GraphQL/ResolverLock.php | 17 ++++++++++++++ src/Appwrite/GraphQL/Resolvers.php | 33 ++++++++++----------------- 2 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 src/Appwrite/GraphQL/ResolverLock.php diff --git a/src/Appwrite/GraphQL/ResolverLock.php b/src/Appwrite/GraphQL/ResolverLock.php new file mode 100644 index 0000000000..24d6d249b0 --- /dev/null +++ b/src/Appwrite/GraphQL/ResolverLock.php @@ -0,0 +1,17 @@ +channel = new Channel(1); + } +} diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 9e409b39ae..cde649bb02 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,9 +6,7 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; -use stdClass; use Swoole\Coroutine; -use Swoole\Coroutine\Channel; use Utopia\DI\Container; use Utopia\Http\Exception; use Utopia\Http\Http; @@ -31,23 +29,17 @@ class Resolvers /** * Get the request-scoped lock shared by GraphQL resolver coroutines * for the current HTTP request. - * - * @return stdClass{channel: Channel, owner: int|null, depth: int} */ - private static function getLock(Http $utopia): stdClass + private static function getLock(Http $utopia): ResolverLock { $container = self::getResolverContainer($utopia); if (!$container->has('graphql:lock')) { - $lock = new stdClass(); - $lock->channel = new Channel(1); - $lock->owner = null; - $lock->depth = 0; + $lock = new ResolverLock(); $container->set('graphql:lock', static fn () => $lock); } - /** @var stdClass{channel: Channel, owner: int|null, depth: int} $lock */ $lock = $container->get('graphql:lock'); return $lock; @@ -56,10 +48,8 @@ class Resolvers /** * Acquire the request-scoped resolver lock. Re-entering from the * same coroutine only increments depth to avoid self-deadlock. - * - * @param stdClass{channel: Channel, owner: int|null, depth: int} $lock */ - private static function acquireLock(stdClass $lock): void + private static function acquireLock(ResolverLock $lock): void { $cid = Coroutine::getCid(); @@ -75,10 +65,8 @@ class Resolvers /** * Release the request-scoped resolver lock. - * - * @param stdClass{channel: Channel, owner: int|null, depth: int} $lock */ - private static function releaseLock(stdClass $lock): void + private static function releaseLock(ResolverLock $lock): void { if ($lock->owner !== Coroutine::getCid()) { return; @@ -93,6 +81,7 @@ class Resolvers $lock->owner = null; $lock->channel->pop(); } + /** * Create a resolver for a given API {@see Route}. * @@ -330,7 +319,7 @@ class Resolvers * @param Http $utopia * @param Request $request * @param Response $response - * @param stdClass{channel: Channel, owner: int|null, depth: int} $lock + * @param ResolverLock $lock * @param callable $resolve * @param callable $reject * @param callable|null $beforeResolve @@ -342,7 +331,7 @@ class Resolvers Http $utopia, Request $request, Response $response, - stdClass $lock, + ResolverLock $lock, callable $resolve, callable $reject, ?callable $beforeResolve = null, @@ -354,10 +343,12 @@ class Resolvers } $request = clone $request; - $utopia->setResource('request', static fn () => $request); + $container = self::getResolverContainer($utopia); self::acquireLock($lock); try { + $container->set('request', static fn () => $request); + $container->set('response', static fn () => $response); $response->setContentType(Response::CONTENT_TYPE_NULL); $response->clearSent(); @@ -368,14 +359,14 @@ class Resolvers $payload = $response->getPayload(); $statusCode = $response->getStatusCode(); } catch (\Throwable $e) { - self::releaseLock($lock); if ($beforeReject) { $e = $beforeReject($e); } $reject($e); return; + } finally { + self::releaseLock($lock); } - self::releaseLock($lock); if ($statusCode < 200 || $statusCode >= 400) { if ($beforeReject) { From f4f5494b85cbcef10d86b1c3e30bfdf423012130 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 17:48:48 +0530 Subject: [PATCH 21/96] fix: isolate graphql resolver responses --- src/Appwrite/GraphQL/Resolvers.php | 80 ++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index cde649bb02..a321c196ce 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -15,6 +15,57 @@ use Utopia\System\System; class Resolvers { + /** + * Request-scoped locks keyed by the per-request GraphQL Http instance. + * + * @var \WeakMap|null + */ + private static ?\WeakMap $locks = null; + + /** + * Clone the shared GraphQL response so each resolver writes into an + * isolated payload and status buffer. + */ + private static function createResolverResponse(Http $utopia): Response + { + /** @var Response $response */ + $response = clone $utopia->getResource('response'); + + return $response; + } + + /** + * Preserve response side effects that callers depend on, such as session + * cookies created by account auth routes. + */ + private static function mergeResponseSideEffects(Response $from, Response $to): void + { + foreach ($from->getCookies() as $cookie) { + $to->removeCookie($cookie['name']); + $to->addCookie( + $cookie['name'], + $cookie['value'], + $cookie['expire'], + $cookie['path'], + $cookie['domain'], + $cookie['secure'], + $cookie['httponly'], + $cookie['samesite'] + ); + } + + $headers = $from->getHeaders(); + $fallbackCookies = $headers['X-Fallback-Cookies'] ?? null; + if ($fallbackCookies === null) { + return; + } + + $to->removeHeader('X-Fallback-Cookies'); + foreach ((array) $fallbackCookies as $value) { + $to->addHeader('X-Fallback-Cookies', $value); + } + } + /** * Get the current request container. */ @@ -32,17 +83,12 @@ class Resolvers */ private static function getLock(Http $utopia): ResolverLock { - $container = self::getResolverContainer($utopia); - - if (!$container->has('graphql:lock')) { - $lock = new ResolverLock(); - - $container->set('graphql:lock', static fn () => $lock); + self::$locks ??= new \WeakMap(); + if (!isset(self::$locks[$utopia])) { + self::$locks[$utopia] = new ResolverLock(); } - $lock = $container->get('graphql:lock'); - - return $lock; + return self::$locks[$utopia]; } /** @@ -343,21 +389,25 @@ class Resolvers } $request = clone $request; + $resolverResponse = self::createResolverResponse($utopia); $container = self::getResolverContainer($utopia); self::acquireLock($lock); try { $container->set('request', static fn () => $request); - $container->set('response', static fn () => $response); - $response->setContentType(Response::CONTENT_TYPE_NULL); - $response->clearSent(); + $container->set('response', static fn () => $resolverResponse); + $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL); + $resolverResponse->clearSent(); $route = $utopia->match($request, fresh: true); + $request->setRoute($route); - $utopia->execute($route, $request, $response); + $utopia->execute($route, $request, $resolverResponse); - $payload = $response->getPayload(); - $statusCode = $response->getStatusCode(); + self::mergeResponseSideEffects($resolverResponse, $response); + + $payload = $resolverResponse->getPayload(); + $statusCode = $resolverResponse->getStatusCode(); } catch (\Throwable $e) { if ($beforeReject) { $e = $beforeReject($e); From d40a355d9de308d6569637e41341d2bd94d7194a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 18:21:39 +0530 Subject: [PATCH 22/96] refactor: simplify audit event context --- src/Appwrite/Event/Context/Audit.php | 142 ++++----------------------- 1 file changed, 21 insertions(+), 121 deletions(-) diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php index 3cfd785ff8..1d41890476 100644 --- a/src/Appwrite/Event/Context/Audit.php +++ b/src/Appwrite/Event/Context/Audit.php @@ -6,129 +6,29 @@ use Utopia\Database\Document; class Audit { - protected ?Document $project = null; - - protected ?Document $user = null; - - protected string $mode = ''; - - protected string $userAgent = ''; - - protected string $ip = ''; - - protected string $hostname = ''; - - protected string $event = ''; - - protected string $resource = ''; - - protected array $payload = []; - - public function setProject(Document $project): self - { - $this->project = $project; - - return $this; + public function __construct( + public ?Document $project = null, + public ?Document $user = null, + public string $mode = '', + public string $userAgent = '', + public string $ip = '', + public string $hostname = '', + public string $event = '', + public string $resource = '', + public array $payload = [], + ) { } - public function getProject(): ?Document + public function isEmpty(): bool { - return $this->project; - } - - public function setUser(Document $user): self - { - $this->user = $user; - - return $this; - } - - public function getUser(): ?Document - { - return $this->user; - } - - public function setMode(string $mode): self - { - $this->mode = $mode; - - return $this; - } - - public function getMode(): string - { - return $this->mode; - } - - public function setUserAgent(string $userAgent): self - { - $this->userAgent = $userAgent; - - return $this; - } - - public function getUserAgent(): string - { - return $this->userAgent; - } - - public function setIP(string $ip): self - { - $this->ip = $ip; - - return $this; - } - - public function getIP(): string - { - return $this->ip; - } - - public function setHostname(string $hostname): self - { - $this->hostname = $hostname; - - return $this; - } - - public function getHostname(): string - { - return $this->hostname; - } - - public function setEvent(string $event): self - { - $this->event = $event; - - return $this; - } - - public function getEvent(): string - { - return $this->event; - } - - public function setResource(string $resource): self - { - $this->resource = $resource; - - return $this; - } - - public function getResource(): string - { - return $this->resource; - } - - public function setPayload(array $payload): self - { - $this->payload = $payload; - - return $this; - } - - public function getPayload(): array - { - return $this->payload; + return $this->project === null + && $this->user === null + && $this->mode === '' + && $this->userAgent === '' + && $this->ip === '' + && $this->hostname === '' + && $this->event === '' + && $this->resource === '' + && $this->payload === []; } } From a1342b4b9d3bc5e6bd80b4a352a457804da4e1a3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 18:32:38 +0530 Subject: [PATCH 23/96] fix: update audit context usage --- app/controllers/shared/api.php | 49 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 6c7532959b..0611983407 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -194,7 +194,7 @@ Http::init() 'name' => $apiKey->getName(), ]); - $auditContext->setUser($user); + $auditContext->user = $user; } // For standard keys, update last accessed time @@ -265,7 +265,7 @@ Http::init() API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, default => ACTIVITY_TYPE_KEY_PROJECT, }); - $auditContext->setUser($userClone); + $auditContext->user = $userClone; } // Apply permission @@ -596,13 +596,12 @@ Http::init() ->setProject($project) ->setUser($user); - $auditContext - ->setMode($mode) - ->setUserAgent($request->getUserAgent('')) - ->setIP($request->getIP()) - ->setHostname($request->getHostname()) - ->setEvent($route->getLabel('audits.event', '')) - ->setProject($project); + $auditContext->mode = $mode; + $auditContext->userAgent = $request->getUserAgent(''); + $auditContext->ip = $request->getIP(); + $auditContext->hostname = $request->getHostname(); + $auditContext->event = $route->getLabel('audits.event', ''); + $auditContext->project = $project; /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { @@ -611,7 +610,7 @@ Http::init() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $auditContext->setUser($userClone); + $auditContext->user = $userClone; } /* Auto-set projects */ @@ -903,7 +902,7 @@ Http::shutdown() if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); if (! empty($resource) && $resource !== $pattern) { - $auditContext->setResource($resource); + $auditContext->resource = $resource; } } @@ -913,8 +912,8 @@ Http::shutdown() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $auditContext->setUser($userClone); - } elseif ($auditContext->getUser() === null || $auditContext->getUser()->isEmpty()) { + $auditContext->user = $userClone; + } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { /** * User in the request is empty, and no user was set for auditing previously. * This indicates: @@ -932,30 +931,30 @@ Http::shutdown() 'name' => 'Guest', ]); - $auditContext->setUser($user); + $auditContext->user = $user; } - $auditUser = $auditContext->getUser(); - if (! empty($auditContext->getResource()) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + $auditUser = $auditContext->user; + if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { - $auditContext->setPayload($responsePayload); + $auditContext->payload = $responsePayload; } $publisherForAudits->enqueue(new \Appwrite\Event\Message\Audit( - project: $auditContext->getProject() ?? new Document(), + project: $auditContext->project ?? new Document(), user: $auditUser, - payload: $auditContext->getPayload(), - resource: $auditContext->getResource(), - mode: $auditContext->getMode(), - ip: $auditContext->getIP(), - userAgent: $auditContext->getUserAgent(), - event: $auditContext->getEvent(), - hostname: $auditContext->getHostname(), + payload: $auditContext->payload, + resource: $auditContext->resource, + mode: $auditContext->mode, + ip: $auditContext->ip, + userAgent: $auditContext->userAgent, + event: $auditContext->event, + hostname: $auditContext->hostname, )); } From d52d6c0bf0a1c4964699c4b19cbd3b9d910164bc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 14 Apr 2026 01:13:32 +1200 Subject: [PATCH 24/96] (fix): reset route to avoid clobbering otel --- src/Appwrite/GraphQL/Resolvers.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 65f8a64d68..3f9ea27e34 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -264,6 +264,8 @@ class Resolvers $response->setContentType(Response::CONTENT_TYPE_NULL); $response->clearSent(); + $originalRoute = $utopia->getRoute(); + try { $route = $utopia->match($request, fresh: true); @@ -274,6 +276,10 @@ class Resolvers } $reject($e); return; + } finally { + if ($originalRoute !== null) { + $utopia->setRoute($originalRoute); + } } $payload = $response->getPayload(); From cc8eb62c83fd5490056bb770c429bee62993b3ba Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 14 Apr 2026 01:15:06 +1200 Subject: [PATCH 25/96] (chore): rename --- src/Appwrite/GraphQL/Resolvers.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 3f9ea27e34..0de97f4953 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -264,7 +264,7 @@ class Resolvers $response->setContentType(Response::CONTENT_TYPE_NULL); $response->clearSent(); - $originalRoute = $utopia->getRoute(); + $original = $utopia->getRoute(); try { $route = $utopia->match($request, fresh: true); @@ -277,8 +277,8 @@ class Resolvers $reject($e); return; } finally { - if ($originalRoute !== null) { - $utopia->setRoute($originalRoute); + if ($original !== null) { + $utopia->setRoute($original); } } From fe02964ebda7ac81301e5a29eb3ac18cd958ecd0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 19:01:20 +0530 Subject: [PATCH 26/96] fix: finalize graphql coroutine response isolation --- app/http.php | 3 +- src/Appwrite/GraphQL/Resolvers.php | 205 +++++++++++++++++------------ src/Appwrite/Promises/Promise.php | 38 ++++-- src/Appwrite/Utopia/Response.php | 12 ++ 4 files changed, 161 insertions(+), 97 deletions(-) diff --git a/app/http.php b/app/http.php index 67da67376d..afcc2d2d0f 100644 --- a/app/http.php +++ b/app/http.php @@ -72,8 +72,6 @@ $swooleAdapter = new Server( container: $container, ); -$container->set('container', fn () => fn () => $swooleAdapter->getContainer()); - $http = $swooleAdapter->getServer(); /** @@ -533,6 +531,7 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files } $requestContainer = $swooleAdapter->getContainer(); + $requestContainer->set('container', fn () => $requestContainer); $requestContainer->set('request', fn () => $request); $requestContainer->set('response', fn () => $response); diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index a321c196ce..83342bc31d 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -71,10 +71,15 @@ class Resolvers */ private static function getResolverContainer(Http $utopia): Container { - /** @var callable(): Container $getContainer */ - $getContainer = $utopia->getResource('container'); + $container = $utopia->getResource('container'); - return $getContainer(); + if ($container instanceof Container || (\is_object($container) && \method_exists($container, 'get') && \method_exists($container, 'set'))) { + /** @var Container $container */ + return $container; + } + + /** @var callable(): Container $container */ + return $container(); } /** @@ -140,33 +145,38 @@ class Resolvers ?Route $route, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $route, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $path = $route->getPath(); - foreach ($args as $key => $value) { - if (\str_contains($path, '/:' . $key)) { - $path = \str_replace(':' . $key, $value, $path); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($route, $args): void { + $path = $route->getPath(); + foreach ($args as $key => $value) { + if (\str_contains($path, '/:' . $key)) { + $path = \str_replace(':' . $key, $value, $path); + } + } + + $request->setMethod($route->getMethod()); + $request->setURI($path); + + switch ($route->getMethod()) { + case 'GET': + $request->setQueryString($args); + break; + default: + $request->setPayload($args); + break; + } } - } - - $request->setMethod($route->getMethod()); - $request->setURI($path); - - switch ($route->getMethod()) { - case 'GET': - $request->setQueryString($args); - break; - default: - $request->setPayload($args); - break; - } - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + ); }); })(); } @@ -209,17 +219,22 @@ class Resolvers callable $url, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -242,22 +257,28 @@ class Resolvers callable $params, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setQueryString($params($databaseId, $collectionId, $args)); - $beforeResolve = function ($payload) { return $payload['documents']; }; - self::resolve($utopia, $request, $response, $lock, $resolve, $reject, $beforeResolve); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + beforeResolve: $beforeResolve, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setQueryString($params($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -280,18 +301,23 @@ class Resolvers callable $params, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('POST'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('POST'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -314,18 +340,23 @@ class Resolvers callable $params, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('PATCH'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('PATCH'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -346,17 +377,22 @@ class Resolvers callable $url, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('DELETE'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('DELETE'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -365,11 +401,10 @@ class Resolvers * @param Http $utopia * @param Request $request * @param Response $response - * @param ResolverLock $lock * @param callable $resolve * @param callable $reject * @param callable|null $beforeResolve - * @param callable|null $beforeReject + * @param callable|null $prepareRequest * @return void * @throws Exception */ @@ -377,23 +412,28 @@ class Resolvers Http $utopia, Request $request, Response $response, - ResolverLock $lock, callable $resolve, callable $reject, ?callable $beforeResolve = null, - ?callable $beforeReject = null, + ?callable $prepareRequest = null, ): void { - // Drop json content type so post args are used directly - if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { - $request->removeHeader('content-type'); - } - - $request = clone $request; - $resolverResponse = self::createResolverResponse($utopia); - $container = self::getResolverContainer($utopia); + $lock = self::getLock($utopia); self::acquireLock($lock); try { + $request = clone $request; + + // Drop json content type so post args are used directly. + if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { + $request->removeHeader('content-type'); + } + + if ($prepareRequest) { + $prepareRequest($request); + } + + $resolverResponse = self::createResolverResponse($utopia); + $container = self::getResolverContainer($utopia); $container->set('request', static fn () => $request); $container->set('response', static fn () => $resolverResponse); $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL); @@ -406,12 +446,18 @@ class Resolvers self::mergeResponseSideEffects($resolverResponse, $response); + if ($resolverResponse->isSent()) { + $response + ->setStatusCode($resolverResponse->getStatusCode()) + ->markSent(); + + $resolve(null); + return; + } + $payload = $resolverResponse->getPayload(); $statusCode = $resolverResponse->getStatusCode(); } catch (\Throwable $e) { - if ($beforeReject) { - $e = $beforeReject($e); - } $reject($e); return; } finally { @@ -419,9 +465,6 @@ class Resolvers } if ($statusCode < 200 || $statusCode >= 400) { - if ($beforeReject) { - $payload = $beforeReject($payload); - } $reject(new GQLException( message: $payload['message'], code: $statusCode diff --git a/src/Appwrite/Promises/Promise.php b/src/Appwrite/Promises/Promise.php index a58c7c29a8..579969cd7b 100644 --- a/src/Appwrite/Promises/Promise.php +++ b/src/Appwrite/Promises/Promise.php @@ -19,8 +19,7 @@ abstract class Promise return; } $resolve = function ($value) { - $this->setResult($value); - $this->setState(self::STATE_FULFILLED); + $this->setState($this->setResult($value)); }; $reject = function ($value) { $this->setResult($value); @@ -106,6 +105,11 @@ abstract class Promise } $callable = $this->isFulfilled() ? $onFulfilled : $onRejected; if (!\is_callable($callable)) { + if ($this->isRejected()) { + $reject($this->result); + return; + } + $resolve($this->result); return; } @@ -126,30 +130,36 @@ abstract class Promise abstract public static function all(iterable $promises): self; /** - * Set resolved result + * Set the resolved result, adopting nested promises while preserving + * whether the adopted promise fulfilled or rejected. * * @param mixed $value - * @return void + * @return int */ - protected function setResult(mixed $value): void + protected function setResult(mixed $value): int { if (!\is_callable([$value, 'then'])) { $this->result = $value; - return; + return self::STATE_FULFILLED; } - $resolved = false; + $state = self::STATE_PENDING; - $callable = function ($value) use (&$resolved) { - $this->setResult($value); - $resolved = true; - }; + $value->then( + function ($value) use (&$state) { + $state = $this->setResult($value); + }, + function ($value) use (&$state) { + $this->result = $value; + $state = self::STATE_REJECTED; + } + ); - $value->then($callable, $callable); - - while (!$resolved) { + while ($state === self::STATE_PENDING) { usleep(25000); } + + return $state; } /** diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 5cd0e8366a..2e920a8cc7 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -613,6 +613,8 @@ class Response extends SwooleResponse throw new \Exception('Response body is not a valid JSON object.'); } + $this->payload = \is_array($data) ? $data : (array) $data; + $this ->setContentType(Response::CONTENT_TYPE_JSON, self::CHARSET_UTF8) ->send(\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); @@ -637,6 +639,16 @@ class Response extends SwooleResponse return $this; } + /** + * Mark the response as already sent so later callers do not attempt to + * write a second payload to the same underlying Swoole response. + */ + public function markSent(): static + { + $this->sent = true; + return $this; + } + /** * Function to add a response filter, the order of filters are first in - first out. * From 4b2e22d9da68bb331cf077c92d2b99fa096fa747 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 09:27:14 +0530 Subject: [PATCH 27/96] Fix graphql-php audit vulnerability --- composer.json | 2 +- composer.lock | 54 +++++++++++++++--------- src/Appwrite/GraphQL/Types/Assoc.php | 4 +- src/Appwrite/GraphQL/Types/InputFile.php | 4 +- src/Appwrite/GraphQL/Types/Json.php | 4 +- 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/composer.json b/composer.json index 4ad1ae6120..5b4a30e150 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "14.11.*", + "webonyx/graphql-php": "^15.31.5", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index 84febe60a8..f3da5433ca 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4fb974e9843f6104e40396e7cad4a833", + "content-hash": "36dc376acce480e002a8c3d07aff7628", "packages": [ { "name": "adhocore/jwt", @@ -5381,38 +5381,48 @@ }, { "name": "webonyx/graphql-php", - "version": "v14.11.10", + "version": "v15.31.5", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19" + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "php": "^7.1 || ^8" + "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.3", - "doctrine/coding-standard": "^6.0", - "nyholm/psr7": "^1.2", + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.94.2", + "mll-lab/php-cs-fixer-config": "5.13.0", + "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "0.12.82", - "phpstan/phpstan-phpunit": "0.12.18", - "phpstan/phpstan-strict-rules": "0.12.9", - "phpunit/phpunit": "^7.2 || ^8.5", - "psr/http-message": "^1.0", - "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0" + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.46", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.10", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", + "thecodingmachine/safe": "^1.3 || ^2 || ^3", + "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" }, @@ -5434,15 +5444,19 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10" + "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" }, "funding": [ + { + "url": "https://github.com/spawnia", + "type": "github" + }, { "url": "https://opencollective.com/webonyx-graphql-php", "type": "open_collective" } ], - "time": "2023-07-05T14:23:37+00:00" + "time": "2026-04-11T18:06:15+00:00" } ], "packages-dev": [ @@ -8449,5 +8463,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php index f76b23dd7a..7205e91573 100644 --- a/src/Appwrite/GraphQL/Types/Assoc.php +++ b/src/Appwrite/GraphQL/Types/Assoc.php @@ -7,8 +7,8 @@ use GraphQL\Language\AST\Node; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Assoc extends Json { - public $name = 'Assoc'; - public $description = 'The `Assoc` scalar type represents associative array values.'; + public string $name = 'Assoc'; + public ?string $description = 'The `Assoc` scalar type represents associative array values.'; public function serialize($value) { diff --git a/src/Appwrite/GraphQL/Types/InputFile.php b/src/Appwrite/GraphQL/Types/InputFile.php index 39fd4e23b3..daa771911b 100644 --- a/src/Appwrite/GraphQL/Types/InputFile.php +++ b/src/Appwrite/GraphQL/Types/InputFile.php @@ -8,8 +8,8 @@ use GraphQL\Type\Definition\ScalarType; class InputFile extends ScalarType { - public $name = 'InputFile'; - public $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by + public string $name = 'InputFile'; + public ?string $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).'; public function serialize($value) diff --git a/src/Appwrite/GraphQL/Types/Json.php b/src/Appwrite/GraphQL/Types/Json.php index 18d27322a1..627b9081d3 100644 --- a/src/Appwrite/GraphQL/Types/Json.php +++ b/src/Appwrite/GraphQL/Types/Json.php @@ -14,8 +14,8 @@ use GraphQL\Type\Definition\ScalarType; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Json extends ScalarType { - public $name = 'Json'; - public $description = 'The `JSON` scalar type represents JSON values as specified by + public string $name = 'Json'; + public ?string $description = 'The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).'; public function serialize($value) From bcfec8d5de3ad7ac8df853d71bc193c8ff0b3f43 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 09:35:25 +0530 Subject: [PATCH 28/96] Align graphql version pinning style --- composer.json | 2 +- composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 5b4a30e150..3aa6d157cf 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "^15.31.5", + "webonyx/graphql-php": "15.31.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index f3da5433ca..4afe17abff 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "36dc376acce480e002a8c3d07aff7628", + "content-hash": "f6a87c1012b316e614258f8f57a28e48", "packages": [ { "name": "adhocore/jwt", From efadf17bfe2f01c5d13adcaeb56c4c21acdc9c95 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 10:26:59 +0530 Subject: [PATCH 29/96] Fix GraphQL 15 static analysis --- app/controllers/api/graphql.php | 2 +- src/Appwrite/GraphQL/Promises/Adapter.php | 4 ++-- src/Appwrite/GraphQL/Promises/Adapter/Swoole.php | 6 +++++- src/Appwrite/GraphQL/Types/Assoc.php | 7 ++++++- tests/unit/GraphQL/BuilderTest.php | 4 +++- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 937380b643..9ec2479749 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -231,7 +231,7 @@ function execute( $validations = GraphQL::getStandardValidationRules(); if (System::getEnv('_APP_GRAPHQL_INTROSPECTION', 'enabled') === 'disabled') { - $validations[] = new DisableIntrospection(); + $validations[] = new DisableIntrospection(DisableIntrospection::ENABLED); } if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled') { diff --git a/src/Appwrite/GraphQL/Promises/Adapter.php b/src/Appwrite/GraphQL/Promises/Adapter.php index 86270f2a8b..1d9cc4557f 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter.php +++ b/src/Appwrite/GraphQL/Promises/Adapter.php @@ -81,8 +81,8 @@ abstract class Adapter implements PromiseAdapter /** * Create a new promise that resolves when all passed in promises resolve. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return GQLPromise */ - abstract public function all(array $promisesOrValues): GQLPromise; + abstract public function all(iterable $promisesOrValues): GQLPromise; } diff --git a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php index efe6eb2f50..af6441ad6d 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php +++ b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php @@ -35,8 +35,12 @@ class Swoole extends Adapter return new GQLPromise($promise, $this); } - public function all(array $promisesOrValues): GQLPromise + public function all(iterable $promisesOrValues): GQLPromise { + if ($promisesOrValues instanceof \Traversable) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + return new GQLPromise(SwoolePromise::all($promisesOrValues), $this); } } diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php index 7205e91573..15bd742d1d 100644 --- a/src/Appwrite/GraphQL/Types/Assoc.php +++ b/src/Appwrite/GraphQL/Types/Assoc.php @@ -3,6 +3,7 @@ namespace Appwrite\GraphQL\Types; use GraphQL\Language\AST\Node; +use GraphQL\Language\AST\StringValueNode; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Assoc extends Json @@ -30,6 +31,10 @@ class Assoc extends Json public function parseLiteral(Node $valueNode, ?array $variables = null) { - return \json_decode($valueNode->value, true); + if ($valueNode instanceof StringValueNode) { + return \json_decode($valueNode->value, true); + } + + return parent::parseLiteral($valueNode, $variables); } } diff --git a/tests/unit/GraphQL/BuilderTest.php b/tests/unit/GraphQL/BuilderTest.php index 3dd1bcadc7..9190ce3e78 100644 --- a/tests/unit/GraphQL/BuilderTest.php +++ b/tests/unit/GraphQL/BuilderTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\GraphQL; use Appwrite\GraphQL\Types\Mapper; use Appwrite\Utopia\Response; +use GraphQL\Type\Definition\NamedType; use PHPUnit\Framework\TestCase; use Swoole\Http\Response as SwooleResponse; @@ -24,6 +25,7 @@ class BuilderTest extends TestCase { $model = $this->response->getModel(Response::MODEL_TABLE); $type = Mapper::model(\ucfirst($model->getType())); - $this->assertEquals('Table', $type->name); + $this->assertInstanceOf(NamedType::class, $type); + $this->assertEquals('Table', $type->name()); } } From 512a7ae2bd0dabf1a0b4e7b3652b5e865986fe77 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 14 Apr 2026 13:36:19 +0300 Subject: [PATCH 30/96] feat: enhance function scheduling with tracing support --- .../Platform/Tasks/ScheduleFunctions.php | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 88725a190a..f867884801 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -7,6 +7,8 @@ use Cron\CronExpression; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Span\Span; +use Utopia\System\System; /** * ScheduleFunctions @@ -88,7 +90,7 @@ class ScheduleFunctions extends ScheduleBase $scheduleKey = $delayConfig['key']; // Ensure schedule was not deleted if (!\array_key_exists($scheduleKey, $this->schedules)) { - return; + continue; } $schedule = $this->schedules[$scheduleKey]; @@ -102,8 +104,22 @@ class ScheduleFunctions extends ScheduleBase ->setFunction($schedule['resource']) ->setMethod('POST') ->setPath('/') - ->setProject($schedule['project']) - ->trigger(); + ->setProject($schedule['project']); + + $projectDoc = $schedule['project']; + $functionDoc = $schedule['resource']; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $projectDoc->getId() === $traceProjectId && $functionDoc->getId() === $traceFunctionId) { + Span::init('execution.trace.v1_functions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $projectDoc->getId()); + Span::add('functionId', $functionDoc->getId()); + Span::add('scheduleId', $schedule['$id'] ?? ''); + Span::current()?->finish(); + } + + $queueForFunctions->trigger(); $this->recordEnqueueDelay($delayConfig['nextDate']); } From 70c380fa3695af94e560fc2228a63cd3d19e4876 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 14 Apr 2026 13:36:29 +0300 Subject: [PATCH 31/96] feat: add tracing support for execution events in log and worker classes --- app/cli.php | 2 +- src/Appwrite/Bus/Listeners/Log.php | 24 ++++++++- src/Appwrite/Platform/Workers/Executions.php | 16 ++++++ src/Appwrite/Platform/Workers/Functions.php | 56 ++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/app/cli.php b/app/cli.php index 73908510d9..bfa3a02cd1 100644 --- a/app/cli.php +++ b/app/cli.php @@ -346,6 +346,6 @@ $cli $cli->shutdown()->action(fn () => Timer::clearAll()); Runtime::enableCoroutine(SWOOLE_HOOK_ALL); -require_once __DIR__ . '/init/span.php'; +require_once __DIR__ . '/init/ '; run($cli->run(...)); Console::exit($exitCode); diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php index 076ed5c02d..585d4b09a7 100644 --- a/src/Appwrite/Bus/Listeners/Log.php +++ b/src/Appwrite/Bus/Listeners/Log.php @@ -7,6 +7,8 @@ use Appwrite\Event\Message\Execution as ExecutionMessage; use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Utopia\Bus\Listener; use Utopia\Database\Document; +use Utopia\Span\Span; +use Utopia\System\System; class Log extends Listener { @@ -30,9 +32,27 @@ class Log extends Listener public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void { + $project = new Document($event->project); + $execution = new Document($event->execution); + if ($execution->getAttribute('resourceType', '') === 'functions') { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.v1_executions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + } + $publisherForExecutions->enqueue(new ExecutionMessage( - project: new Document($event->project), - execution: new Document($event->execution), + project: $project, + execution: $execution, )); } } diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 673e9de791..99e20be035 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -7,6 +7,8 @@ use Exception; use Utopia\Database\Database; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; +use Utopia\System\System; class Executions extends Action { @@ -39,6 +41,20 @@ class Executions extends Action throw new Exception('Missing execution'); } + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $executionMessage->project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.executions_worker_upsert'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $executionMessage->project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('resourceType', $execution->getAttribute('resourceType', '')); + Span::current()?->finish(); + } + $dbForProject->upsertDocument('executions', $execution); } } diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index bed28dad1c..0899fbacb4 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -23,6 +23,7 @@ use Utopia\Database\Query; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; use Utopia\System\System; class Functions extends Action @@ -115,6 +116,22 @@ class Functions extends Action $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); + if (empty($events) && !$function->isEmpty()) { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_dequeue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('payloadType', $type); + Span::add('queuePid', $message->getPid()); + Span::add('queueName', $message->getQueue()); + Span::add('messageTimestamp', (string) $message->getTimestamp()); + Span::current()?->finish(); + } + } + if (!empty($events)) { $limit = 100; $sum = 100; @@ -304,6 +321,20 @@ class Functions extends Action 'duration' => 0.0, ]); + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus_fail'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('trigger', $trigger); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), @@ -522,6 +553,18 @@ class Functions extends Action $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_executor'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $executionId); + Span::add('deploymentId', $deployment->getId()); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $executionResponse = $executor->createExecution( projectId: $project->getId(), deploymentId: $deploymentId, @@ -594,6 +637,19 @@ class Functions extends Action $errorCode = $th->getCode(); } finally { /** Persist final execution status and record usage */ + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), From 6d5968e2eab9b3bf6c5a476fbc1f01ddd3933a30 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 16:51:46 +0530 Subject: [PATCH 32/96] ci: disable functional GraphQL e2e --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aeae21655..494a9c1424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,7 +512,7 @@ jobs: # Services that rely on sequential test method execution (shared static state) FUNCTIONAL_FLAG="--functional" case "${{ matrix.service }}" in - Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;; + Databases|TablesDB|Functions|Realtime|GraphQL) FUNCTIONAL_FLAG="" ;; esac docker compose exec -T \ From 71533aaaf39d055e846282b779ac9e68140d22e7 Mon Sep 17 00:00:00 2001 From: Shimon Newman Date: Tue, 14 Apr 2026 15:17:37 +0300 Subject: [PATCH 33/96] Update app/cli.php Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- app/cli.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cli.php b/app/cli.php index bfa3a02cd1..73908510d9 100644 --- a/app/cli.php +++ b/app/cli.php @@ -346,6 +346,6 @@ $cli $cli->shutdown()->action(fn () => Timer::clearAll()); Runtime::enableCoroutine(SWOOLE_HOOK_ALL); -require_once __DIR__ . '/init/ '; +require_once __DIR__ . '/init/span.php'; run($cli->run(...)); Console::exit($exitCode); From 82798fa5a3bd3d24447122061ea2ba3eb500c588 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 18:18:25 +0530 Subject: [PATCH 34/96] Simplify audit message construction --- app/controllers/shared/api.php | 248 ++++----------------------- src/Appwrite/Event/Message/Audit.php | 36 ++-- 2 files changed, 58 insertions(+), 226 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index d3248b54ce..1798d31c58 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -10,6 +10,7 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Audit as AuditMessage; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Messaging; use Appwrite\Event\Publisher\Audit; @@ -64,7 +65,6 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar if (array_key_exists($replace, $params)) { $replacement = $params[$replace]; - // Convert to string if it's not already a string if (! is_string($replacement)) { if (is_array($replacement)) { $replacement = json_encode($replacement); @@ -104,83 +104,27 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); } - /** - * Handle user authentication and session validation. - * - * This function follows a series of steps to determine the appropriate user session - * based on cookies, headers, and JWT tokens. - * - * Process: - * - * Project & Role Validation: - * 1. Check if the project is empty. If so, throw an exception. - * 2. Get the roles configuration. - * 3. Determine the role for the user based on the user document. - * 4. Get the scopes for the role. - * - * API Key Authentication: - * 5. If there is an API key: - * - Verify no user session exists simultaneously - * - Check if key is expired - * - Set role and scopes from API key - * - Handle special app role case - * - For standard keys, update last accessed time - * - * User Activity: - * 6. If the project is not the console and user is not admin: - * - Update user's last activity timestamp - * - * Access Control: - * 7. Get the method from the route - * 8. Validate namespace permissions - * 9. Validate scope permissions - * 10. Check if user is blocked - * - * Security Checks: - * 11. Verify password status (check if reset required) - * 12. Validate MFA requirements: - * - Check if MFA is enabled - * - Verify email status - * - Verify phone status - * - Verify authenticator status - * 13. Handle Multi-Factor Authentication: - * - Check remaining required factors - * - Validate factor completion - * - Throw exception if factors incomplete - */ - - // Step 1: Check if project is empty if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } - // Step 2: Get roles configuration $roles = Config::getParam('roles', []); - // Step 3: Determine role for user - // TODO get scopes from the identity instead of the user roles config. The identity will containn the scopes the user authorized for the access token. - $role = $user->isEmpty() ? Role::guests()->toString() : Role::users()->toString(); - // Step 4: Get scopes for the role $scopes = $roles[$role]['scopes']; - // Step 5: API Key Authentication if (! empty($apiKey)) { - // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); } - // Set role and scopes from API key $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); - // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { - // Disable authorization checks for project API keys if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } @@ -197,7 +141,6 @@ Http::init() $auditContext->user = $user; } - // For standard keys, update last accessed time if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { $dbKey = null; if (! empty($apiKey->getProjectId())) { @@ -268,7 +211,6 @@ Http::init() $auditContext->user = $userClone; } - // Apply permission if ($apiKey->getType() === API_KEY_ORGANIZATION) { $authorization->addRole(Role::team($team->getId())->toString()); $authorization->addRole(Role::team($team->getId(), 'owner')->toString()); @@ -296,8 +238,7 @@ Http::init() $authorization->addRole('label:' . $nodeLabel); } } - } // Admin User Authentication - elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { + } elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { $teamId = $team->getId(); $adminRoles = []; $memberships = $user->getAttribute('memberships', []); @@ -318,8 +259,6 @@ Http::init() $projectId = explode('/', $uri)[3]; } - // Base scopes for admin users to allow listing teams and projects. - // Useful for those who have project-specific roles but don't have team-wide role. $scopes = ['teams.read', 'projects.read']; foreach ($adminRoles as $adminRole) { $isTeamWideRole = ! str_starts_with($adminRole, 'project-'); @@ -336,22 +275,15 @@ Http::init() } } - /** - * For console projects resource, we use platform DB. - * Enabling authorization restricts admin user to the projects they have access to. - */ if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { $authorization->setDefaultStatus(true); } else { - // Otherwise, disable authorization checks. $authorization->setDefaultStatus(false); } } $scopes = \array_unique($scopes); - // Intentional: impersonators get users.read so they can discover a target user - // before impersonation starts, and keep that access while impersonating. if ( !$user->isEmpty() && ( @@ -368,11 +300,6 @@ Http::init() $authorization->addRole($authRole); } - /** - * We disable authorization checks above to ensure other endpoints (list teams, members, etc.) will continue working. - * But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check - * whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them). - */ if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { $input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ)); $initialStatus = $authorization->getStatus(); @@ -383,7 +310,6 @@ Http::init() $authorization->setStatus($initialStatus); } - // Step 6: Update project and user last activity if (! $project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { @@ -397,7 +323,6 @@ Http::init() $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $accessedAt = $user->getAttribute('accessedAt', 0); - // Skip updating accessedAt for impersonated requests so we don't attribute activity to the target user. if (! $impersonatorUserId && DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { $user->setAttribute('accessedAt', DateTime::now()); @@ -413,14 +338,8 @@ Http::init() } } - // Steps 7-9: Access Control - Method, Namespace and Scope Validation - /** - * @var ?Method $method - */ $method = $route->getLabel('sdk', false); - // Take the first method if there's more than one, - // namespace can not differ between methods on the same route if (\is_array($method)) { $method = $method[0]; } @@ -437,7 +356,6 @@ Http::init() } } - // Step 8b: Check REST protocol status if ( array_key_exists('rest', $project->getAttribute('apis', [])) && ! $project->getAttribute('apis', [])['rest'] @@ -446,23 +364,19 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } - // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')'); } - // Step 10: Check if user is blocked - if ($user->getAttribute('status') === false) { // Account is blocked + if ($user->getAttribute('status') === false) { throw new Exception(Exception::USER_BLOCKED); } - // Step 11: Verify password status if ($user->getAttribute('reset')) { throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); } - // Step 12: Validate MFA requirements $mfaEnabled = $user->getAttribute('mfa', false); $hasVerifiedEmail = $user->getAttribute('emailVerification', false); $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); @@ -470,7 +384,6 @@ Http::init() $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; - // Step 13: Handle Multi-Factor Authentication if (! in_array('mfa', $route->getGroups())) { if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) { throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); @@ -515,83 +428,36 @@ Http::init() } $path = $route->getMatchedPath(); - $databaseType = match (true) { - str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, - str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, - default => '', - }; - - /* - * Abuse Check - */ - - $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); - $timeLimitArray = []; - - $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; - - foreach ($abuseKeyLabel as $abuseKey) { - $start = $request->getContentRangeStart(); - $end = $request->getContentRangeEnd(); - $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); - $timeLimit - ->setParam('{projectId}', $project->getId()) - ->setParam('{userId}', $user->getId()) - ->setParam('{userAgent}', $request->getUserAgent('')) - ->setParam('{ip}', $request->getIP()) - ->setParam('{url}', $request->getHostname() . $route->getPath()) - ->setParam('{method}', $request->getMethod()) - ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - $timeLimitArray[] = $timeLimit; + if (strpos($request->getProtocol(), 'http') === 0) { + $path = $request->getProtocol() . '://' . $request->getHostname() . $path; } - - $closestLimit = null; - - $roles = $authorization->getRoles(); - $isPrivilegedUser = $user->isPrivileged($roles); - $isAppUser = $user->isApp($roles); - - foreach ($timeLimitArray as $timeLimit) { - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys - if (! empty($value)) { - $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); - } - } - - $abuse = new Abuse($timeLimit); - $remaining = $timeLimit->remaining(); - - $limit = $timeLimit->limit(); - $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600); - - if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) { - $closestLimit = $remaining; - $response - ->addHeader('X-RateLimit-Limit', $limit) - ->addHeader('X-RateLimit-Remaining', $remaining) - ->addHeader('X-RateLimit-Reset', $time); - } - - $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; - - if ( - $enabled // Abuse is enabled - && ! $isAppUser // User is not API key - && ! $isPrivilegedUser // User is not an admin - && $devKey->isEmpty() // request doesn't not contain development key - && $abuse->check() // Route is rate-limited - ) { - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED); + if (strpos($path, ':') !== false) { + $params = $route->getParams(); + foreach ($params as $key => $param) { + $path = str_replace(':' . $key, $param, $path); } } - /** - * TODO: (@loks0n) - * Avoid mutating the message across file boundaries - it's difficult to reason about at scale. - */ - /* - * Background Jobs - */ + $response + ->addHeader('X-Debug-Speed', APP_VERSION_STABLE) + ->addHeader('X-Appwrite-Project', $project->getId()) + ->addHeader('X-Appwrite-Region', System::getEnv('_APP_REGION', 'fra')); + + if (! empty(APP_OPTIONS_ABUSE)) { + $response->addHeader('X-Appwrite-Abuse-Limit', APP_OPTIONS_ABUSE); + } + + $request + ->setProtocol($request->getProtocol()) + ->setHostname($request->getHostname()) + ->setPath($path) + ->setMethod($request->getMethod()) + ->setProject($project) + ->setUser($user); + + $route->setLabel('sdk.url', $path); + $route->setLabel('sdk.name', $project->getAttribute('name')); + $queueForEvents ->setEvent($route->getLabel('event', '')) ->setProject($project) @@ -604,17 +470,14 @@ Http::init() $auditContext->event = $route->getLabel('audits.event', ''); $auditContext->project = $project; - /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { $userClone = clone $user; - // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } - /* Auto-set projects */ $queueForDeletes->setProject($project); $queueForDatabase->setProject($project); $queueForMessaging->setProject($project); @@ -622,7 +485,6 @@ Http::init() $queueForBuilds->setProject($project); $queueForMails->setProject($project); - /* Auto-set platforms */ $queueForFunctions->setPlatform($platform); $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); @@ -640,7 +502,7 @@ Http::init() $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) ); - $timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched. + $timestamp = 60 * 60 * 24 * 180; $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { @@ -686,7 +548,6 @@ Http::init() } Span::add('storage.bucket.id', $bucketId); Span::add('storage.file.id', $fileId); - // Do not update transformedAt if it's a console user if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { @@ -703,7 +564,6 @@ Http::init() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => DateTime::now(), ]))); - // Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid $cache->save($key, $data); } @@ -742,12 +602,6 @@ Http::init() } }); -/** - * Limit user session - * - * Delete older sessions if the number of sessions have crossed - * the session limit set for the project - */ Http::shutdown() ->groups(['session']) ->inject('utopia') @@ -817,11 +671,9 @@ Http::shutdown() $queueForEvents->setPayload($responsePayload); } - // Get project and function/webhook events (cached) $functionsEvents = $eventProcessor->getFunctionsEvents($project, $dbForProject); $webhooksEvents = $eventProcessor->getWebhooksEvents($project); - // Generate events for this operation $generatedEvents = Event::generateEvents( $queueForEvents->getEvent(), $queueForEvents->getParams() @@ -833,7 +685,6 @@ Http::shutdown() ->trigger(); } - // Only trigger functions if there are matching function events if (! empty($functionsEvents)) { foreach ($generatedEvents as $event) { if (isset($functionsEvents[$event])) { @@ -845,7 +696,6 @@ Http::shutdown() } } - // Only trigger webhooks if there are matching webhook events if (! empty($webhooksEvents)) { foreach ($generatedEvents as $event) { if (isset($webhooksEvents[$event])) { @@ -861,9 +711,6 @@ Http::shutdown() $route = $utopia->getRoute(); $requestParams = $route->getParamsValues(); - /** - * Abuse labels - */ $abuseEnabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; $abuseResetCode = $route->getLabel('abuse-reset', []); $abuseResetCode = \is_array($abuseResetCode) ? $abuseResetCode : [$abuseResetCode]; @@ -885,7 +732,7 @@ Http::shutdown() ->setParam('{method}', $request->getMethod()) ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + foreach ($request->getParams() as $key => $value) { if (! empty($value)) { $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); } @@ -896,9 +743,6 @@ Http::shutdown() } } - /** - * Audit labels - */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); @@ -909,20 +753,11 @@ Http::shutdown() if (! $user->isEmpty()) { $userClone = clone $user; - // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { - /** - * User in the request is empty, and no user was set for auditing previously. - * This indicates: - * - No API Key was used. - * - No active session exists. - * - * Therefore, we consider this an anonymous request and create a relevant user. - */ $user = new User([ '$id' => '', 'status' => true, @@ -937,26 +772,12 @@ Http::shutdown() $auditUser = $auditContext->user; if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { - /** - * audits.payload is switched to default true - * in order to auto audit payload for all endpoints - */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { $auditContext->payload = $responsePayload; } - $publisherForAudits->enqueue(new \Appwrite\Event\Message\Audit( - project: $auditContext->project ?? new Document(), - user: $auditUser, - payload: $auditContext->payload, - resource: $auditContext->resource, - mode: $auditContext->mode, - ip: $auditContext->ip, - userAgent: $auditContext->userAgent, - event: $auditContext->event, - hostname: $auditContext->hostname, - )); + $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext)); } if (! empty($queueForDeletes->getType())) { @@ -975,7 +796,6 @@ Http::shutdown() $queueForMessaging->trigger(); } - // Cache label $useCache = $route->getLabel('cache', false); if ($useCache) { $resource = $resourceType = null; @@ -1011,7 +831,6 @@ Http::shutdown() 'signature' => $signature, ]))); } catch (DuplicateException) { - // Race condition: another concurrent request already created the cache document $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); } } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { @@ -1019,7 +838,6 @@ Http::shutdown() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => $cacheLog->getAttribute('accessedAt') ]))); - // Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load() $cache->save($key, $data['payload']); } @@ -1038,11 +856,9 @@ Http::shutdown() )); } - // Publish usage metrics if context has data if (! $usage->isEmpty()) { $metrics = $usage->getMetrics(); - // Filter out API key disabled metrics using suffix pattern matching $disabledMetrics = $apiKey?->getDisabledMetrics() ?? []; if (! empty($disabledMetrics)) { $metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) { diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php index febd96b072..ae5831c3b9 100644 --- a/src/Appwrite/Event/Message/Audit.php +++ b/src/Appwrite/Event/Message/Audit.php @@ -2,20 +2,21 @@ namespace Appwrite\Event\Message; +use Appwrite\Event\Context\Audit as AuditContext; use Utopia\Database\Document; final class Audit extends Base { public function __construct( - public readonly Document $project, - public readonly Document $user, - public readonly array $payload, - public readonly string $resource, - public readonly string $mode, - public readonly string $ip, - public readonly string $userAgent, public readonly string $event, - public readonly string $hostname, + public readonly array $payload, + public readonly Document $project = new Document(), + public readonly Document $user = new Document(), + public readonly string $resource = '', + public readonly string $mode = '', + public readonly string $ip = '', + public readonly string $userAgent = '', + public readonly string $hostname = '', ) { } @@ -41,15 +42,30 @@ final class Audit extends Base public static function fromArray(array $data): static { return new self( + event: $data['event'] ?? '', + payload: $data['payload'] ?? [], project: new Document($data['project'] ?? []), user: new Document($data['user'] ?? []), - payload: $data['payload'] ?? [], resource: $data['resource'] ?? '', mode: $data['mode'] ?? '', ip: $data['ip'] ?? '', userAgent: $data['userAgent'] ?? '', - event: $data['event'] ?? '', hostname: $data['hostname'] ?? '', ); } + + public static function fromContext(AuditContext $context): static + { + return new self( + event: $context->event, + payload: $context->payload, + project: $context->project ?? new Document(), + user: $context->user ?? new Document(), + resource: $context->resource, + mode: $context->mode, + ip: $context->ip, + userAgent: $context->userAgent, + hostname: $context->hostname, + ); + } } From b2884ddb886c4ab244195af39bdace2d51ae6dfc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 18:23:24 +0530 Subject: [PATCH 35/96] Use audit message context helper --- app/controllers/shared/api.php | 235 ++++++++++++++++++++++++++++----- 1 file changed, 205 insertions(+), 30 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 1798d31c58..5567281e67 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -65,6 +65,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar if (array_key_exists($replace, $params)) { $replacement = $params[$replace]; + // Convert to string if it's not already a string if (! is_string($replacement)) { if (is_array($replacement)) { $replacement = json_encode($replacement); @@ -104,27 +105,83 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); } + /** + * Handle user authentication and session validation. + * + * This function follows a series of steps to determine the appropriate user session + * based on cookies, headers, and JWT tokens. + * + * Process: + * + * Project & Role Validation: + * 1. Check if the project is empty. If so, throw an exception. + * 2. Get the roles configuration. + * 3. Determine the role for the user based on the user document. + * 4. Get the scopes for the role. + * + * API Key Authentication: + * 5. If there is an API key: + * - Verify no user session exists simultaneously + * - Check if key is expired + * - Set role and scopes from API key + * - Handle special app role case + * - For standard keys, update last accessed time + * + * User Activity: + * 6. If the project is not the console and user is not admin: + * - Update user's last activity timestamp + * + * Access Control: + * 7. Get the method from the route + * 8. Validate namespace permissions + * 9. Validate scope permissions + * 10. Check if user is blocked + * + * Security Checks: + * 11. Verify password status (check if reset required) + * 12. Validate MFA requirements: + * - Check if MFA is enabled + * - Verify email status + * - Verify phone status + * - Verify authenticator status + * 13. Handle Multi-Factor Authentication: + * - Check remaining required factors + * - Validate factor completion + * - Throw exception if factors incomplete + */ + + // Step 1: Check if project is empty if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } + // Step 2: Get roles configuration $roles = Config::getParam('roles', []); + // Step 3: Determine role for user + // TODO get scopes from the identity instead of the user roles config. The identity will containn the scopes the user authorized for the access token. + $role = $user->isEmpty() ? Role::guests()->toString() : Role::users()->toString(); + // Step 4: Get scopes for the role $scopes = $roles[$role]['scopes']; + // Step 5: API Key Authentication if (! empty($apiKey)) { + // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); } + // Set role and scopes from API key $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); + // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { + // Disable authorization checks for project API keys if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } @@ -141,6 +198,7 @@ Http::init() $auditContext->user = $user; } + // For standard keys, update last accessed time if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { $dbKey = null; if (! empty($apiKey->getProjectId())) { @@ -211,6 +269,7 @@ Http::init() $auditContext->user = $userClone; } + // Apply permission if ($apiKey->getType() === API_KEY_ORGANIZATION) { $authorization->addRole(Role::team($team->getId())->toString()); $authorization->addRole(Role::team($team->getId(), 'owner')->toString()); @@ -238,7 +297,8 @@ Http::init() $authorization->addRole('label:' . $nodeLabel); } } - } elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { + } // Admin User Authentication + elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { $teamId = $team->getId(); $adminRoles = []; $memberships = $user->getAttribute('memberships', []); @@ -259,6 +319,8 @@ Http::init() $projectId = explode('/', $uri)[3]; } + // Base scopes for admin users to allow listing teams and projects. + // Useful for those who have project-specific roles but don't have team-wide role. $scopes = ['teams.read', 'projects.read']; foreach ($adminRoles as $adminRole) { $isTeamWideRole = ! str_starts_with($adminRole, 'project-'); @@ -275,15 +337,22 @@ Http::init() } } + /** + * For console projects resource, we use platform DB. + * Enabling authorization restricts admin user to the projects they have access to. + */ if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { $authorization->setDefaultStatus(true); } else { + // Otherwise, disable authorization checks. $authorization->setDefaultStatus(false); } } $scopes = \array_unique($scopes); + // Intentional: impersonators get users.read so they can discover a target user + // before impersonation starts, and keep that access while impersonating. if ( !$user->isEmpty() && ( @@ -300,6 +369,11 @@ Http::init() $authorization->addRole($authRole); } + /** + * We disable authorization checks above to ensure other endpoints (list teams, members, etc.) will continue working. + * But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check + * whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them). + */ if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { $input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ)); $initialStatus = $authorization->getStatus(); @@ -310,6 +384,7 @@ Http::init() $authorization->setStatus($initialStatus); } + // Step 6: Update project and user last activity if (! $project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { @@ -323,6 +398,7 @@ Http::init() $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $accessedAt = $user->getAttribute('accessedAt', 0); + // Skip updating accessedAt for impersonated requests so we don't attribute activity to the target user. if (! $impersonatorUserId && DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { $user->setAttribute('accessedAt', DateTime::now()); @@ -338,8 +414,14 @@ Http::init() } } + // Steps 7-9: Access Control - Method, Namespace and Scope Validation + /** + * @var ?Method $method + */ $method = $route->getLabel('sdk', false); + // Take the first method if there's more than one, + // namespace can not differ between methods on the same route if (\is_array($method)) { $method = $method[0]; } @@ -356,6 +438,7 @@ Http::init() } } + // Step 8b: Check REST protocol status if ( array_key_exists('rest', $project->getAttribute('apis', [])) && ! $project->getAttribute('apis', [])['rest'] @@ -364,19 +447,23 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } + // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')'); } - if ($user->getAttribute('status') === false) { + // Step 10: Check if user is blocked + if ($user->getAttribute('status') === false) { // Account is blocked throw new Exception(Exception::USER_BLOCKED); } + // Step 11: Verify password status if ($user->getAttribute('reset')) { throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); } + // Step 12: Validate MFA requirements $mfaEnabled = $user->getAttribute('mfa', false); $hasVerifiedEmail = $user->getAttribute('emailVerification', false); $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); @@ -384,6 +471,7 @@ Http::init() $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; + // Step 13: Handle Multi-Factor Authentication if (! in_array('mfa', $route->getGroups())) { if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) { throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); @@ -428,36 +516,83 @@ Http::init() } $path = $route->getMatchedPath(); - if (strpos($request->getProtocol(), 'http') === 0) { - $path = $request->getProtocol() . '://' . $request->getHostname() . $path; + $databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => '', + }; + + /* + * Abuse Check + */ + + $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); + $timeLimitArray = []; + + $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; + + foreach ($abuseKeyLabel as $abuseKey) { + $start = $request->getContentRangeStart(); + $end = $request->getContentRangeEnd(); + $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); + $timeLimit + ->setParam('{projectId}', $project->getId()) + ->setParam('{userId}', $user->getId()) + ->setParam('{userAgent}', $request->getUserAgent('')) + ->setParam('{ip}', $request->getIP()) + ->setParam('{url}', $request->getHostname() . $route->getPath()) + ->setParam('{method}', $request->getMethod()) + ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); + $timeLimitArray[] = $timeLimit; } - if (strpos($path, ':') !== false) { - $params = $route->getParams(); - foreach ($params as $key => $param) { - $path = str_replace(':' . $key, $param, $path); + + $closestLimit = null; + + $roles = $authorization->getRoles(); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); + + foreach ($timeLimitArray as $timeLimit) { + foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + if (! empty($value)) { + $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); + } + } + + $abuse = new Abuse($timeLimit); + $remaining = $timeLimit->remaining(); + + $limit = $timeLimit->limit(); + $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600); + + if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) { + $closestLimit = $remaining; + $response + ->addHeader('X-RateLimit-Limit', $limit) + ->addHeader('X-RateLimit-Remaining', $remaining) + ->addHeader('X-RateLimit-Reset', $time); + } + + $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; + + if ( + $enabled // Abuse is enabled + && ! $isAppUser // User is not API key + && ! $isPrivilegedUser // User is not an admin + && $devKey->isEmpty() // request doesn't not contain development key + && $abuse->check() // Route is rate-limited + ) { + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED); } } - $response - ->addHeader('X-Debug-Speed', APP_VERSION_STABLE) - ->addHeader('X-Appwrite-Project', $project->getId()) - ->addHeader('X-Appwrite-Region', System::getEnv('_APP_REGION', 'fra')); - - if (! empty(APP_OPTIONS_ABUSE)) { - $response->addHeader('X-Appwrite-Abuse-Limit', APP_OPTIONS_ABUSE); - } - - $request - ->setProtocol($request->getProtocol()) - ->setHostname($request->getHostname()) - ->setPath($path) - ->setMethod($request->getMethod()) - ->setProject($project) - ->setUser($user); - - $route->setLabel('sdk.url', $path); - $route->setLabel('sdk.name', $project->getAttribute('name')); - + /** + * TODO: (@loks0n) + * Avoid mutating the message across file boundaries - it's difficult to reason about at scale. + */ + /* + * Background Jobs + */ $queueForEvents ->setEvent($route->getLabel('event', '')) ->setProject($project) @@ -470,14 +605,17 @@ Http::init() $auditContext->event = $route->getLabel('audits.event', ''); $auditContext->project = $project; + /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { $userClone = clone $user; + // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } + /* Auto-set projects */ $queueForDeletes->setProject($project); $queueForDatabase->setProject($project); $queueForMessaging->setProject($project); @@ -485,6 +623,7 @@ Http::init() $queueForBuilds->setProject($project); $queueForMails->setProject($project); + /* Auto-set platforms */ $queueForFunctions->setPlatform($platform); $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); @@ -502,7 +641,7 @@ Http::init() $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) ); - $timestamp = 60 * 60 * 24 * 180; + $timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched. $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { @@ -548,6 +687,7 @@ Http::init() } Span::add('storage.bucket.id', $bucketId); Span::add('storage.file.id', $fileId); + // Do not update transformedAt if it's a console user if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { @@ -564,6 +704,7 @@ Http::init() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => DateTime::now(), ]))); + // Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid $cache->save($key, $data); } @@ -602,6 +743,12 @@ Http::init() } }); +/** + * Limit user session + * + * Delete older sessions if the number of sessions have crossed + * the session limit set for the project + */ Http::shutdown() ->groups(['session']) ->inject('utopia') @@ -671,9 +818,11 @@ Http::shutdown() $queueForEvents->setPayload($responsePayload); } + // Get project and function/webhook events (cached) $functionsEvents = $eventProcessor->getFunctionsEvents($project, $dbForProject); $webhooksEvents = $eventProcessor->getWebhooksEvents($project); + // Generate events for this operation $generatedEvents = Event::generateEvents( $queueForEvents->getEvent(), $queueForEvents->getParams() @@ -685,6 +834,7 @@ Http::shutdown() ->trigger(); } + // Only trigger functions if there are matching function events if (! empty($functionsEvents)) { foreach ($generatedEvents as $event) { if (isset($functionsEvents[$event])) { @@ -696,6 +846,7 @@ Http::shutdown() } } + // Only trigger webhooks if there are matching webhook events if (! empty($webhooksEvents)) { foreach ($generatedEvents as $event) { if (isset($webhooksEvents[$event])) { @@ -711,6 +862,9 @@ Http::shutdown() $route = $utopia->getRoute(); $requestParams = $route->getParamsValues(); + /** + * Abuse labels + */ $abuseEnabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; $abuseResetCode = $route->getLabel('abuse-reset', []); $abuseResetCode = \is_array($abuseResetCode) ? $abuseResetCode : [$abuseResetCode]; @@ -732,7 +886,7 @@ Http::shutdown() ->setParam('{method}', $request->getMethod()) ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - foreach ($request->getParams() as $key => $value) { + foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys if (! empty($value)) { $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); } @@ -743,6 +897,9 @@ Http::shutdown() } } + /** + * Audit labels + */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); @@ -753,11 +910,20 @@ Http::shutdown() if (! $user->isEmpty()) { $userClone = clone $user; + // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { + /** + * User in the request is empty, and no user was set for auditing previously. + * This indicates: + * - No API Key was used. + * - No active session exists. + * + * Therefore, we consider this an anonymous request and create a relevant user. + */ $user = new User([ '$id' => '', 'status' => true, @@ -772,6 +938,10 @@ Http::shutdown() $auditUser = $auditContext->user; if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + /** + * audits.payload is switched to default true + * in order to auto audit payload for all endpoints + */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { $auditContext->payload = $responsePayload; @@ -796,6 +966,7 @@ Http::shutdown() $queueForMessaging->trigger(); } + // Cache label $useCache = $route->getLabel('cache', false); if ($useCache) { $resource = $resourceType = null; @@ -831,6 +1002,7 @@ Http::shutdown() 'signature' => $signature, ]))); } catch (DuplicateException) { + // Race condition: another concurrent request already created the cache document $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); } } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { @@ -838,6 +1010,7 @@ Http::shutdown() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => $cacheLog->getAttribute('accessedAt') ]))); + // Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load() $cache->save($key, $data['payload']); } @@ -856,9 +1029,11 @@ Http::shutdown() )); } + // Publish usage metrics if context has data if (! $usage->isEmpty()) { $metrics = $usage->getMetrics(); + // Filter out API key disabled metrics using suffix pattern matching $disabledMetrics = $apiKey?->getDisabledMetrics() ?? []; if (! empty($disabledMetrics)) { $metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) { From 4d4ba508efd91a6288ee9e9f5331d90e415a7535 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 08:47:41 +0530 Subject: [PATCH 36/96] fix: use spl_object_hash for lock keys instead of WeakMap Replace WeakMap with a plain array keyed by spl_object_hash($utopia) as suggested in review. Entry is cleaned up in the finally block to prevent leaks. --- src/Appwrite/GraphQL/Resolvers.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index c13ceeebd3..24b3874cd3 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -18,9 +18,9 @@ class Resolvers /** * Request-scoped locks keyed by the per-request GraphQL Http instance. * - * @var \WeakMap|null + * @var array */ - private static ?\WeakMap $locks = null; + private static array $locks = []; /** * Clone the shared GraphQL response so each resolver writes into an @@ -88,12 +88,12 @@ class Resolvers */ private static function getLock(Http $utopia): ResolverLock { - self::$locks ??= new \WeakMap(); - if (!isset(self::$locks[$utopia])) { - self::$locks[$utopia] = new ResolverLock(); + $key = \spl_object_hash($utopia); + if (!isset(self::$locks[$key])) { + self::$locks[$key] = new ResolverLock(); } - return self::$locks[$utopia]; + return self::$locks[$key]; } /** @@ -468,6 +468,7 @@ class Resolvers } self::releaseLock($lock); + unset(self::$locks[\spl_object_hash($utopia)]); } if ($statusCode < 200 || $statusCode >= 400) { From 50640b5bb95e57567a5366bc9536142a746a9e84 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 08:50:14 +0530 Subject: [PATCH 37/96] =?UTF-8?q?refactor:=20inline=20createResolverRespon?= =?UTF-8?q?se=20=E2=80=94=20only=20called=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Appwrite/GraphQL/Resolvers.php | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 24b3874cd3..b1688571f1 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -22,18 +22,6 @@ class Resolvers */ private static array $locks = []; - /** - * Clone the shared GraphQL response so each resolver writes into an - * isolated payload and status buffer. - */ - private static function createResolverResponse(Http $utopia): Response - { - /** @var Response $response */ - $response = clone $utopia->getResource('response'); - - return $response; - } - /** * Preserve response side effects that callers depend on, such as session * cookies created by account auth routes. @@ -434,7 +422,8 @@ class Resolvers $prepareRequest($request); } - $resolverResponse = self::createResolverResponse($utopia); + /** @var Response $resolverResponse */ + $resolverResponse = clone $utopia->getResource('response'); $container = self::getResolverContainer($utopia); $container->set('request', static fn () => $request); $container->set('response', static fn () => $resolverResponse); From 54d647016376f1819854e96adb51eb50ac7d3538 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 08:51:14 +0530 Subject: [PATCH 38/96] refactor: move acquire/release into ResolverLock as instance methods --- src/Appwrite/GraphQL/ResolverLock.php | 38 ++++++++++++++++++++++++ src/Appwrite/GraphQL/Resolvers.php | 42 ++------------------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/Appwrite/GraphQL/ResolverLock.php b/src/Appwrite/GraphQL/ResolverLock.php index 24d6d249b0..b1cdcf3d53 100644 --- a/src/Appwrite/GraphQL/ResolverLock.php +++ b/src/Appwrite/GraphQL/ResolverLock.php @@ -2,6 +2,7 @@ namespace Appwrite\GraphQL; +use Swoole\Coroutine; use Swoole\Coroutine\Channel; final class ResolverLock @@ -14,4 +15,41 @@ final class ResolverLock { $this->channel = new Channel(1); } + + /** + * Acquire the lock. Re-entering from the same coroutine only + * increments depth to avoid self-deadlock. + */ + public function acquire(): void + { + $cid = Coroutine::getCid(); + + if ($this->owner === $cid) { + $this->depth++; + return; + } + + $this->channel->push(true); + $this->owner = $cid; + $this->depth = 1; + } + + /** + * Release the lock. + */ + public function release(): void + { + if ($this->owner !== Coroutine::getCid()) { + return; + } + + $this->depth--; + + if ($this->depth > 0) { + return; + } + + $this->owner = null; + $this->channel->pop(); + } } diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index b1688571f1..3bb90375d9 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,7 +6,6 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; -use Swoole\Coroutine; use Utopia\DI\Container; use Utopia\Http\Exception; use Utopia\Http\Http; @@ -84,43 +83,6 @@ class Resolvers return self::$locks[$key]; } - /** - * Acquire the request-scoped resolver lock. Re-entering from the - * same coroutine only increments depth to avoid self-deadlock. - */ - private static function acquireLock(ResolverLock $lock): void - { - $cid = Coroutine::getCid(); - - if ($lock->owner === $cid) { - $lock->depth++; - return; - } - - $lock->channel->push(true); - $lock->owner = $cid; - $lock->depth = 1; - } - - /** - * Release the request-scoped resolver lock. - */ - private static function releaseLock(ResolverLock $lock): void - { - if ($lock->owner !== Coroutine::getCid()) { - return; - } - - $lock->depth--; - - if ($lock->depth > 0) { - return; - } - - $lock->owner = null; - $lock->channel->pop(); - } - /** * Create a resolver for a given API {@see Route}. * @@ -407,7 +369,7 @@ class Resolvers ): void { $lock = self::getLock($utopia); - self::acquireLock($lock); + $lock->acquire(); $original = $utopia->getRoute(); try { @@ -456,7 +418,7 @@ class Resolvers $utopia->setRoute($original); } - self::releaseLock($lock); + $lock->release(); unset(self::$locks[\spl_object_hash($utopia)]); } From f9efd803c1cada7a9a41f5921db61d0a62ebff3d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 08:55:00 +0530 Subject: [PATCH 39/96] refactor: remove unnecessary IIFE wrappers in resolver methods --- src/Appwrite/GraphQL/Resolvers.php | 246 ++++++++++++++--------------- 1 file changed, 117 insertions(+), 129 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 3bb90375d9..7d88aa59ec 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -94,41 +94,39 @@ class Resolvers Http $utopia, ?Route $route, ): callable { - return static fn ($type, $args, $context, $info) => (function () use ($utopia, $route, $args) { - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - self::resolve( - $utopia, - $request, - $response, - $resolve, - $reject, - prepareRequest: static function (Request $request) use ($route, $args): void { - $path = $route->getPath(); - foreach ($args as $key => $value) { - if (\str_contains($path, '/:' . $key)) { - $path = \str_replace(':' . $key, $value, $path); - } - } - - $request->setMethod($route->getMethod()); - $request->setURI($path); - - switch ($route->getMethod()) { - case 'GET': - $request->setQueryString($args); - break; - default: - $request->setPayload($args); - break; + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($route, $args): void { + $path = $route->getPath(); + foreach ($args as $key => $value) { + if (\str_contains($path, '/:' . $key)) { + $path = \str_replace(':' . $key, $value, $path); } } - ); - }); - })(); + + $request->setMethod($route->getMethod()); + $request->setURI($path); + + switch ($route->getMethod()) { + case 'GET': + $request->setQueryString($args); + break; + default: + $request->setPayload($args); + break; + } + } + ); + }); } /** @@ -168,25 +166,23 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - self::resolve( - $utopia, - $request, - $response, - $resolve, - $reject, - prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - } - ); - }); - })(); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -206,31 +202,29 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $beforeResolve = function ($payload) { - return $payload['documents']; - }; + $beforeResolve = function ($payload) { + return $payload['documents']; + }; - self::resolve( - $utopia, - $request, - $response, - $resolve, - $reject, - beforeResolve: $beforeResolve, - prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setQueryString($params($databaseId, $collectionId, $args)); - } - ); - }); - })(); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + beforeResolve: $beforeResolve, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setQueryString($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -250,26 +244,24 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - self::resolve( - $utopia, - $request, - $response, - $resolve, - $reject, - prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { - $request->setMethod('POST'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - } - ); - }); - })(); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('POST'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -289,26 +281,24 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - self::resolve( - $utopia, - $request, - $response, - $resolve, - $reject, - prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { - $request->setMethod('PATCH'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - } - ); - }); - })(); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('PATCH'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -326,25 +316,23 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - self::resolve( - $utopia, - $request, - $response, - $resolve, - $reject, - prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { - $request->setMethod('DELETE'); - $request->setURI($url($databaseId, $collectionId, $args)); - } - ); - }); - })(); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('DELETE'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); + }); } /** From e77bfae09170868ad4138e557a266b75b7b28557 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 09:19:02 +0530 Subject: [PATCH 40/96] fix: add restart policy to MongoDB container for flaky CI starts MongoDB's official Docker entrypoint uses a two-phase startup: a temporary mongod for user/db init, then the real mongod. Under CI resource pressure the port may not be released between the two phases, causing mongod to exit with code 48 (address already in use). Adding restart: on-failure:3 lets Docker handle the transient failure natively. On restart the data directory already exists so the entrypoint skips the two-phase init entirely, avoiding the race. --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index aa2bfdd16a..391d71fb48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1288,6 +1288,7 @@ services: image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging + restart: on-failure:3 networks: - appwrite volumes: From d40df613de307c47d8b735e5d6c0848840e5d564 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 09:51:26 +0530 Subject: [PATCH 41/96] fix: run ProjectWebhooks tests sequentially in CI ProjectWebhooks tests have shared state dependencies (e.g. index creation must complete before assertions). Running with --functional (parallel methods) causes flaky failures where indexes are still 'processing' instead of 'available'. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 494a9c1424..d8256ddc7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,7 +512,7 @@ jobs: # Services that rely on sequential test method execution (shared static state) FUNCTIONAL_FLAG="--functional" case "${{ matrix.service }}" in - Databases|TablesDB|Functions|Realtime|GraphQL) FUNCTIONAL_FLAG="" ;; + Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;; esac docker compose exec -T \ From f51f02375ae71e2e97953bff53363724e9826519 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 09:52:32 +0530 Subject: [PATCH 42/96] test: remove flaky concurrent session race condition test testEmailPasswordSessionNotCorruptedByConcurrentRequests relies on timing-sensitive curl_multi orchestration with hardcoded delays to reproduce a cache race window. This makes it inherently flaky in CI where resource pressure shifts the timing unpredictably. --- .../Account/AccountCustomClientTest.php | 163 ------------------ 1 file changed, 163 deletions(-) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 951ab179b3..780e43a2a3 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -4171,167 +4171,4 @@ class AccountCustomClientTest extends Scope * a stale user document that lacks the new session, causing sessionVerify * to fail with 401 on subsequent requests using the new session. */ - public function testEmailPasswordSessionNotCorruptedByConcurrentRequests(): void - { - $projectId = $this->getProject()['$id']; - $endpoint = $this->client->getEndpoint(); - - $email = uniqid('race_', true) . getmypid() . '@localhost.test'; - $password = 'password123!'; - - // Create user - $response = $this->client->call(Client::METHOD_POST, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => 'Race Test User', - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - // Login to get session A - $responseA = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'email' => $email, - 'password' => $password, - ]); - $this->assertEquals(201, $responseA['headers']['status-code']); - $sessionA = $responseA['cookies']['a_session_' . $projectId]; - - // Verify session A works - $verifyA = $this->client->call(Client::METHOD_GET, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $sessionA, - ]); - $this->assertEquals(200, $verifyA['headers']['status-code']); - - /** - * Race condition scenario: - * 1. Start login B via curl_multi (non-blocking) - * 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument - * (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead) - * 3. THEN add GET requests to curl_multi - these hit different workers and - * re-cache a stale user document (without session B) during the window - * between purgeCachedDocument and createDocument - * 4. After all complete, verify session B is usable - */ - for ($attempt = 0; $attempt < 5; $attempt++) { - $loginCookies = []; - - $multi = curl_multi_init(); - - // Start login B first (alone) - $loginHandle = curl_init("{$endpoint}/account/sessions/email"); - curl_setopt_array($loginHandle, [ - CURLOPT_POST => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'origin: http://localhost', - 'content-type: application/json', - "x-appwrite-project: {$projectId}", - ], - CURLOPT_POSTFIELDS => \json_encode([ - 'email' => $email, - 'password' => $password, - ]), - CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$loginCookies) { - if (\stripos($header, 'set-cookie:') === 0) { - $cookiePart = \trim(\substr($header, 11)); - $eqPos = \strpos($cookiePart, '='); - if ($eqPos !== false) { - $name = \substr($cookiePart, 0, $eqPos); - $rest = \substr($cookiePart, $eqPos + 1); - $semiPos = \strpos($rest, ';'); - $loginCookies[$name] = $semiPos !== false - ? \substr($rest, 0, $semiPos) - : $rest; - } - } - return \strlen($header); - }, - ]); - curl_multi_add_handle($multi, $loginHandle); - - // Drive the login transfer forward and wait for the server to start - // processing the login (past hash verification + cache purge). - $deadline = \microtime(true) + 0.15; // 150ms - do { - curl_multi_exec($multi, $active); - curl_multi_select($multi, 0.005); - } while (\microtime(true) < $deadline && $active); - - // NOW add GET requests - they arrive after the cache purge - // but before session creation (which is delayed by the usleep or I/O). - $getHandles = []; - for ($i = 0; $i < 10; $i++) { - $gh = curl_init("{$endpoint}/account"); - curl_setopt_array($gh, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'origin: http://localhost', - 'content-type: application/json', - "x-appwrite-project: {$projectId}", - "cookie: a_session_{$projectId}={$sessionA}", - ], - ]); - curl_multi_add_handle($multi, $gh); - $getHandles[] = $gh; - } - - // Drive all to completion - do { - $status = curl_multi_exec($multi, $active); - if ($active) { - curl_multi_select($multi, 0.05); - } - } while ($active && $status === CURLM_OK); - - $loginStatus = curl_getinfo($loginHandle, CURLINFO_HTTP_CODE); - - curl_multi_remove_handle($multi, $loginHandle); - curl_close($loginHandle); - foreach ($getHandles as $gh) { - curl_multi_remove_handle($multi, $gh); - curl_close($gh); - } - curl_multi_close($multi); - - $this->assertEquals(201, $loginStatus, 'Login for session B should succeed'); - - $sessionBCookie = $loginCookies["a_session_{$projectId}"] ?? null; - $this->assertNotNull($sessionBCookie, 'Session B cookie should be set'); - - // THE CRITICAL CHECK: verify session B is usable immediately - $verifyB = $this->client->call(Client::METHOD_GET, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => "a_session_{$projectId}={$sessionBCookie}", - ]); - - $this->assertEquals( - 200, - $verifyB['headers']['status-code'], - 'Session B must be immediately usable after login. ' - . 'A 401 here means a stale user cache (without the new session) was served. ' - . 'The fix is to create the session document BEFORE purging the user cache.' - ); - - // Clean up session B for next iteration - $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => "a_session_{$projectId}={$sessionBCookie}", - ]); - } - } } From 8671533878ad3801879b94f41463dee75154936d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 10:13:37 +0530 Subject: [PATCH 43/96] fix: remove orphaned docblock from deleted test --- .../e2e/Services/Account/AccountCustomClientTest.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 780e43a2a3..49f0c4c245 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -4160,15 +4160,4 @@ class AccountCustomClientTest extends Scope $this->assertEquals(401, $verification3['headers']['status-code']); } - - /** - * Test that a new email/password session is immediately usable even when - * a concurrent request re-populates the user cache between the cache purge - * and session creation. - * - * Regression test for: purging the user cache BEFORE persisting the session - * allows a concurrent request (from a different Swoole worker) to re-cache - * a stale user document that lacks the new session, causing sessionVerify - * to fail with 401 on subsequent requests using the new session. - */ } From 80197b566c4c126348aed8b25eddfc6afb8ebaac Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 10:22:50 +0530 Subject: [PATCH 44/96] fix: replace force-push with regular push in SDK release task The SDK push task used `git push --force-with-lease` which fails on repos with branch protection rules that disallow force pushes. Instead, checkout the existing remote dev branch and commit on top of it so a regular push is always a fast-forward. --- src/Appwrite/Platform/Tasks/SDKs.php | 35 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 4725f4095f..526ea304de 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -622,29 +622,28 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $repo->execute('config', 'advice.defaultBranchName', 'false'); $repo->addRemote('origin', $gitUrl); - // Fetch and checkout base branch (or create if new repo) + // Fetch and checkout the target branch (e.g. dev) if it exists on remote, + // otherwise create it from the base branch (e.g. main). + // We build on top of the existing remote branch so a regular push + // works without force-pushing against protected branches. + $hasBranch = false; try { - $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch); + $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $gitBranch); + $hasBranch = true; + } catch (\Throwable) { + // Branch doesn't exist on remote yet + } + + if ($hasBranch) { + $repo->execute('checkout', '-f', $gitBranch); + } else { + // Fetch base branch to create the target branch from it try { + $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch); $repo->execute('checkout', '-f', $repoBranch); } catch (\Throwable) { $repo->execute('checkout', '-b', $repoBranch); } - } catch (\Throwable) { - $repo->execute('checkout', '-b', $repoBranch); - } - - try { - $repo->execute('pull', 'origin', $repoBranch, '--quiet', '--no-tags'); - } catch (\Throwable) { - } - - // Create or checkout dev branch from the base branch - // This ensures dev always starts from the latest base branch, - // avoiding history divergence caused by squash merges. - try { - $repo->execute('checkout', '-B', $gitBranch, $repoBranch); - } catch (\Throwable) { $repo->execute('checkout', '-b', $gitBranch); } @@ -685,7 +684,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND return true; } - $repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet'); + $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); } catch (\Throwable $e) { Console::warning(" Git push failed: " . $e->getMessage()); return false; From dc185be8364413a8c9e68e3077f643dd21d80072 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 15 Apr 2026 13:52:20 +0530 Subject: [PATCH 45/96] collapse --- src/Appwrite/GraphQL/Resolvers.php | 4 ++-- src/Appwrite/Utopia/Response.php | 19 ++++--------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 7d88aa59ec..cabb357607 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -378,7 +378,7 @@ class Resolvers $container->set('request', static fn () => $request); $container->set('response', static fn () => $resolverResponse); $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL); - $resolverResponse->clearSent(); + $resolverResponse->setSent(false); $route = $utopia->match($request, fresh: true); $request->setRoute($route); @@ -390,7 +390,7 @@ class Resolvers if ($resolverResponse->isSent()) { $response ->setStatusCode($resolverResponse->getStatusCode()) - ->markSent(); + ->setSent(true); $resolve(null); return; diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 2e920a8cc7..04d2813e30 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -629,23 +629,12 @@ class Response extends SwooleResponse } /** - * Reset the sent flag so the response can be reused for another - * action execution (e.g. batched GraphQL queries that share one - * Response instance). + * Set the sent flag on the response. Pass false to allow reuse + * (e.g. batched GraphQL queries), true to prevent further writes. */ - public function clearSent(): static + public function setSent(bool $sent): static { - $this->sent = false; - return $this; - } - - /** - * Mark the response as already sent so later callers do not attempt to - * write a second payload to the same underlying Swoole response. - */ - public function markSent(): static - { - $this->sent = true; + $this->sent = $sent; return $this; } From 1fb78115e8242e85b630597730ee740326f1431b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 15 Apr 2026 17:23:18 +0530 Subject: [PATCH 46/96] added backward compat --- src/Appwrite/Event/Event.php | 33 ++- .../Realtime/RealtimeCustomClientTest.php | 210 +++++++++++++++++- tests/unit/Event/EventTest.php | 31 +++ 3 files changed, 262 insertions(+), 12 deletions(-) diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index ae75e3924f..c5f48cb085 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -662,21 +662,30 @@ class Event } /** - * Adds `table` events for `collection` events. + * Adds table/collection counterpart events for backward compatibility. * * Example: * * `databases.*.collections.*.documents.*.update` →\ * `[databases.*.collections.*.documents.*.update, databases.*.tables.*.rows.*.update]` + * + * `databases.*.tables.*.rows.*.update` →\ + * `[databases.*.tables.*.rows.*.update, databases.*.collections.*.documents.*.update]` */ private static function mirrorCollectionEvents(string $pattern, string $firstEvent, array $events): array { - $tableEventMap = [ + $collectionsToTablesMap = [ 'documents' => 'rows', 'collections' => 'tables', 'attributes' => 'columns', ]; + $tablesToCollectionsMap = [ + 'rows' => 'documents', + 'tables' => 'collections', + 'columns' => 'attributes', + ]; + $databasesEventMap = [ 'tablesdb' => 'databases', 'tables' => 'collections', @@ -687,7 +696,10 @@ class Event if ( ( str_contains($pattern, 'databases.') && - str_contains($firstEvent, 'collections') + ( + str_contains($firstEvent, 'collections') || + str_contains($firstEvent, 'tables') + ) ) || ( str_contains($firstEvent, 'tablesdb.') @@ -705,18 +717,25 @@ class Event ); $pairedEvents[] = $databasesSideEvent; $tableSideEvent = str_replace( - array_keys($tableEventMap), - array_values($tableEventMap), + array_keys($collectionsToTablesMap), + array_values($collectionsToTablesMap), $databasesSideEvent ); $pairedEvents[] = $tableSideEvent; } elseif (str_contains($event, 'collections')) { $tableSideEvent = str_replace( - array_keys($tableEventMap), - array_values($tableEventMap), + array_keys($collectionsToTablesMap), + array_values($collectionsToTablesMap), $event ); $pairedEvents[] = $tableSideEvent; + } elseif (str_contains($event, 'tables')) { + $collectionSideEvent = str_replace( + array_keys($tablesToCollectionsMap), + array_values($tablesToCollectionsMap), + $event + ); + $pairedEvents[] = $collectionSideEvent; } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 9c768f00d1..450318b58f 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3295,6 +3295,201 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + public function testChannelMirrorEventsAcrossDatabasesAndTablesdb(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + /** + * Case 1: Trigger event through /databases route and verify both + * legacy collections/documents and tables/rows events are generated. + */ + $legacyDatabase = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror Legacy DB', + ]); + $this->assertEquals(201, $legacyDatabase['headers']['status-code']); + $legacyDatabaseId = $legacyDatabase['body']['$id']; + + $legacyCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Legacy Collection', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $legacyCollectionId = $legacyCollection['body']['$id']; + + $attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $attribute['headers']['status-code']); + + $this->assertEventually(function () use ($legacyDatabaseId, $legacyCollectionId) { + $attribute = $this->client->call(Client::METHOD_GET, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->assertEquals('available', $attribute['body']['status']); + }, 30000, 250); + + $legacyClient = $this->getWebsocket([ + "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents", + "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows", + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($legacyClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $legacyDocumentId = ID::unique(); + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'documentId' => $legacyDocumentId, + 'data' => [ + 'name' => 'legacy-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $document['headers']['status-code']); + + $legacyEvent = json_decode($legacyClient->receive(), true); + $this->assertEquals('event', $legacyEvent['type']); + $this->assertContains( + "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents.{$legacyDocumentId}.create", + $legacyEvent['data']['events'] + ); + $this->assertContains( + "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows.{$legacyDocumentId}.create", + $legacyEvent['data']['events'] + ); + $legacyClient->close(); + + /** + * Case 2: Trigger event through /tablesdb route and verify both + * tables/rows and legacy collections/documents events are generated. + */ + $tablesDatabase = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror TablesDB', + ]); + $this->assertEquals(201, $tablesDatabase['headers']['status-code']); + $tablesDatabaseId = $tablesDatabase['body']['$id']; + + $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'tableId' => ID::unique(), + 'name' => 'Mirror Table', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + $column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $column['headers']['status-code']); + + $this->assertEventually(function () use ($tablesDatabaseId, $tableId) { + $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders())); + + $this->assertEquals('available', $column['body']['status']); + }, 120000, 500); + + $tablesClient = $this->getWebsocket([ + "databases.{$tablesDatabaseId}.tables.{$tableId}.rows", + "databases.{$tablesDatabaseId}.collections.{$tableId}.documents", + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($tablesClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $rowId = ID::unique(); + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'rowId' => $rowId, + 'data' => [ + 'name' => 'tablesdb-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + + $tablesEvent = json_decode($tablesClient->receive(), true); + $this->assertEquals('event', $tablesEvent['type']); + $this->assertContains( + "databases.{$tablesDatabaseId}.tables.{$tableId}.rows.{$rowId}.create", + $tablesEvent['data']['events'] + ); + $this->assertContains( + "databases.{$tablesDatabaseId}.collections.{$tableId}.documents.{$rowId}.create", + $tablesEvent['data']['events'] + ); + $tablesClient->close(); + } + public function testChannelDatabaseTransactionMultipleOperations() { $user = $this->getUser(); @@ -3968,7 +4163,16 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals(256, $name['body']['size']); $this->assertTrue($name['body']['required']); - sleep(2); + $this->assertEventually(function () use ($databaseId, $actorsId) { + $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders())); + + $this->assertEquals(200, $column['headers']['status-code']); + $this->assertEquals('available', $column['body']['status']); + }, 120000, 500); /** * Test Document Create @@ -5367,8 +5571,6 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('$id', $response['data']['payload']); $this->assertEquals(15, $response['data']['payload']['score']); - sleep(1); - try { $client->receive(); $this->fail('Should not receive duplicate event'); @@ -5400,8 +5602,6 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('$id', $response['data']['payload']); $this->assertEquals(12, $response['data']['payload']['score']); - sleep(1); - try { $client->receive(); $this->fail('Should not receive duplicate event'); diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php index d050ce5f64..99bd2ea7ee 100644 --- a/tests/unit/Event/EventTest.php +++ b/tests/unit/Event/EventTest.php @@ -156,4 +156,35 @@ class EventTest extends TestCase $this->assertInstanceOf(InvalidArgumentException::class, $th, 'An invalid exception was thrown'); } } + + public function testGenerateMirrorEvents(): void + { + $tableRowEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'rowId' => 'row-123', + ]); + $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tableRowEvents); + + $collectionDocumentEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'documentId' => 'doc-123', + ]); + $this->assertContains('databases.factory-db.tables.assembly.rows.doc-123.update', $collectionDocumentEvents); + + $tableColumnEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].columns.[columnId].create', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'columnId' => 'status', + ]); + $this->assertContains('databases.factory-db.collections.assembly.attributes.status.create', $tableColumnEvents); + + $collectionAttributeEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].attributes.[attributeId].create', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'attributeId' => 'status', + ]); + $this->assertContains('databases.factory-db.tables.assembly.columns.status.create', $collectionAttributeEvents); + } } From 7b8fb409b1d6bd1e66a523958cd249a33c08f579 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 15 Apr 2026 17:33:57 +0530 Subject: [PATCH 47/96] added database filtering --- src/Appwrite/Event/Event.php | 8 +++-- .../Realtime/RealtimeCustomClientTest.php | 4 +++ tests/unit/Event/EventTest.php | 29 ++++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index c5f48cb085..d0954dbead 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -637,9 +637,11 @@ class Event */ $eventValues = \array_values($events); - /** - * Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility - */ + $databaseType = $database?->getAttribute('type', 'legacy'); + if ($database !== null && !\in_array($databaseType, ['legacy', 'tablesdb'], true)) { + return $eventValues; + } + return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues); } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 450318b58f..9214b58c10 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -4887,6 +4887,8 @@ class RealtimeCustomClientTest extends Scope $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create", $response['data']['events']); + $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.'))); $this->assertNotEmpty($response['data']['payload']); $this->assertEquals('Chris Evans', $response['data']['payload']['name']); @@ -4918,6 +4920,8 @@ class RealtimeCustomClientTest extends Scope $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); + $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.'))); $this->assertNotEmpty($response['data']['payload']); $this->assertEquals('Chris Evans 2', $response['data']['payload']['name']); diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php index 99bd2ea7ee..f7d10617d7 100644 --- a/tests/unit/Event/EventTest.php +++ b/tests/unit/Event/EventTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Event; use Appwrite\Event\Event; use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Utopia\Database\Document; require_once __DIR__ . '/../../../app/init.php'; @@ -159,32 +160,52 @@ class EventTest extends TestCase public function testGenerateMirrorEvents(): void { + $legacyDatabase = new Document(['type' => 'legacy']); $tableRowEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ 'databaseId' => 'factory-db', 'tableId' => 'assembly', 'rowId' => 'row-123', - ]); + ], $legacyDatabase); $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tableRowEvents); $collectionDocumentEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ 'databaseId' => 'factory-db', 'collectionId' => 'assembly', 'documentId' => 'doc-123', - ]); + ], $legacyDatabase); $this->assertContains('databases.factory-db.tables.assembly.rows.doc-123.update', $collectionDocumentEvents); $tableColumnEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].columns.[columnId].create', [ 'databaseId' => 'factory-db', 'tableId' => 'assembly', 'columnId' => 'status', - ]); + ], $legacyDatabase); $this->assertContains('databases.factory-db.collections.assembly.attributes.status.create', $tableColumnEvents); $collectionAttributeEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].attributes.[attributeId].create', [ 'databaseId' => 'factory-db', 'collectionId' => 'assembly', 'attributeId' => 'status', - ]); + ], $legacyDatabase); $this->assertContains('databases.factory-db.tables.assembly.columns.status.create', $collectionAttributeEvents); + + $tablesDb = new Document(['type' => 'tablesdb']); + $tablesDbEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'rowId' => 'row-123', + ], $tablesDb); + $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tablesDbEvents); + $this->assertContains('tablesdb.factory-db.tables.assembly.rows.row-123.update', $tablesDbEvents); + + $documentsDb = new Document(['type' => 'documentsdb']); + $documentsDbEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'documentId' => 'doc-123', + ], $documentsDb); + $this->assertContains('documentsdb.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents); + $this->assertNotContains('documentsdb.factory-db.tables.assembly.rows.doc-123.update', $documentsDbEvents); + $this->assertNotContains('databases.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents); } } From 6d9b78781610c8962bce0e5316eb11b87b3b0c5f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 15 Apr 2026 17:38:21 +0530 Subject: [PATCH 48/96] updated string replacement --- src/Appwrite/Event/Event.php | 38 +++++----- .../Realtime/RealtimeCustomClientTest.php | 76 +++++++++++++++++++ tests/unit/Event/EventTest.php | 7 ++ 3 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index d0954dbead..fae2d0e843 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -712,31 +712,15 @@ class Event $pairedEvents[] = $event; // tablesdb needs databases event with tables and collections if (str_contains($event, 'tablesdb')) { - $databasesSideEvent = str_replace( - array_keys($databasesEventMap), - array_values($databasesEventMap), - $event - ); + $databasesSideEvent = self::replaceEventSegments($event, $databasesEventMap); $pairedEvents[] = $databasesSideEvent; - $tableSideEvent = str_replace( - array_keys($collectionsToTablesMap), - array_values($collectionsToTablesMap), - $databasesSideEvent - ); + $tableSideEvent = self::replaceEventSegments($databasesSideEvent, $collectionsToTablesMap); $pairedEvents[] = $tableSideEvent; } elseif (str_contains($event, 'collections')) { - $tableSideEvent = str_replace( - array_keys($collectionsToTablesMap), - array_values($collectionsToTablesMap), - $event - ); + $tableSideEvent = self::replaceEventSegments($event, $collectionsToTablesMap); $pairedEvents[] = $tableSideEvent; } elseif (str_contains($event, 'tables')) { - $collectionSideEvent = str_replace( - array_keys($tablesToCollectionsMap), - array_values($tablesToCollectionsMap), - $event - ); + $collectionSideEvent = self::replaceEventSegments($event, $tablesToCollectionsMap); $pairedEvents[] = $collectionSideEvent; } } @@ -749,6 +733,20 @@ class Event return array_values(array_unique($events)); } + /** + * Replace only exact event path segments, never partial substrings. + */ + private static function replaceEventSegments(string $event, array $map): string + { + $parts = \explode('.', $event); + $parts = \array_map( + fn (string $part) => $map[$part] ?? $part, + $parts + ); + + return \implode('.', $parts); + } + /** * Maps event terminology based on database type */ diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 9214b58c10..ca07d45f46 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3488,6 +3488,82 @@ class RealtimeCustomClientTest extends Scope $tablesEvent['data']['events'] ); $tablesClient->close(); + + /** + * Case 3: Trigger event through /documentsdb route and verify only + * documentsdb events are generated (no databases/tablesdb mirrors). + */ + $documentsDatabase = $this->client->call(Client::METHOD_POST, '/documentsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror DocumentsDB', + ]); + $this->assertEquals(201, $documentsDatabase['headers']['status-code']); + $documentsDatabaseId = $documentsDatabase['body']['$id']; + + $documentsCollection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Mirror Documents Collection', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $documentsCollection['headers']['status-code']); + $documentsCollectionId = $documentsCollection['body']['$id']; + + $documentsClient = $this->getWebsocket([ + "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents", + 'documents', + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($documentsClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $documentsDocumentId = ID::unique(); + $documentsDocument = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections/' . $documentsCollectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'documentId' => $documentsDocumentId, + 'data' => [ + 'name' => 'documentsdb-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $documentsDocument['headers']['status-code']); + + $documentsEvent = json_decode($documentsClient->receive(), true); + $this->assertEquals('event', $documentsEvent['type']); + $this->assertContains( + "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents.{$documentsDocumentId}.create", + $documentsEvent['data']['events'] + ); + $this->assertEmpty( + array_filter( + $documentsEvent['data']['events'], + fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.') + ) + ); + $documentsClient->close(); } public function testChannelDatabaseTransactionMultipleOperations() diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php index f7d10617d7..0c03aa04eb 100644 --- a/tests/unit/Event/EventTest.php +++ b/tests/unit/Event/EventTest.php @@ -197,6 +197,13 @@ class EventTest extends TestCase ], $tablesDb); $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tablesDbEvents); $this->assertContains('tablesdb.factory-db.tables.assembly.rows.row-123.update', $tablesDbEvents); + $tableIdWithReservedWordEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'rows-archive', + 'rowId' => 'row-123', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.collections.rows-archive.documents.row-123.update', $tableIdWithReservedWordEvents); + $this->assertNotContains('databases.factory-db.collections.documents-archive.documents.row-123.update', $tableIdWithReservedWordEvents); $documentsDb = new Document(['type' => 'documentsdb']); $documentsDbEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ From 6ba810a4da93b3d206096dec4a798063b2eff9db Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 15 Apr 2026 17:49:33 +0530 Subject: [PATCH 49/96] fix unit tests --- tests/unit/Event/EventTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php index 0c03aa04eb..471dd5ad08 100644 --- a/tests/unit/Event/EventTest.php +++ b/tests/unit/Event/EventTest.php @@ -116,7 +116,7 @@ class EventTest extends TestCase 'rowId' => 'prolog', ]); - $this->assertCount(22, $event); + $this->assertCount(42, $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog.create', $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog', $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.*.create', $event); From 7376c5b5178ae7e6556110002d6a5dffb5794c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 15:09:40 +0200 Subject: [PATCH 50/96] Fix protocol endpoint causing InvalidArgumentException --- .../Modules/Project/Http/Project/Protocols/Status/Update.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php index 1fa2df3566..97dac5d5c5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php @@ -33,8 +33,8 @@ class Update extends Action ->desc('Update project protocol status') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'protocols.[protocol].update') - ->label('audits.event', 'project.protocols.[protocol].update') + ->label('event', 'protocols.[protocolId].update') + ->label('audits.event', 'project.protocols.[protocolId].update') ->label('audits.resource', 'project.protocols/{response.$id}') ->label('sdk', new Method( namespace: 'project', From e7f78b3e01f021104f31f10dcdaba09e4b60159a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 15:29:05 +0200 Subject: [PATCH 51/96] Fix shutdown event errors --- .../Project/Http/Project/Protocols/Status/Update.php | 7 ++++++- .../Project/Http/Project/Services/Status/Update.php | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php index 97dac5d5c5..71c20faca7 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -57,6 +58,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,7 +68,8 @@ class Update extends Action Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + Event $queueForEvents, ): void { $protocols = $project->getAttribute('apis', []); $protocols[$protocolId] = $enabled; @@ -75,6 +78,8 @@ class Update extends Action 'apis' => $protocols, ]))); + $queueForEvents->setParam('protocolId', $protocolId); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php index 35be32a604..f3d9654789 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Services\Status; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -33,8 +34,8 @@ class Update extends Action ->desc('Update project service status') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'services.[service].update') - ->label('audits.event', 'project.services.[service].update') + ->label('event', 'services.[serviceId].update') + ->label('audits.event', 'project.services.[serviceId].update') ->label('audits.resource', 'project.services/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -57,6 +58,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,7 +68,8 @@ class Update extends Action Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + Event $queueForEvents ): void { $services = $project->getAttribute('services', []); $services[$serviceId] = $enabled; @@ -75,6 +78,8 @@ class Update extends Action 'services' => $services, ]))); + $queueForEvents->setParam('serviceId', $serviceId); + $response->dynamic($project, Response::MODEL_PROJECT); } } From 6da132db4697a6f3c38873b5f8e6abce68efe04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:05:27 +0200 Subject: [PATCH 52/96] Remove SMS templates and support null locale for mail templates --- app/controllers/api/projects.php | 226 ++----------------------------- 1 file changed, 11 insertions(+), 215 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5b82e6c1a3..3b83c37acd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -833,74 +833,6 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Get custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSmsTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.getSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSMSTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - $template = [ - 'message' => Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl')->render(), - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_SMS_TEMPLATE); - }); - - Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') ->groups(['api', 'projects']) @@ -920,11 +852,12 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - + ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1000,73 +933,6 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); -Http::patch('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Update custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmsTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMSTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->param('message', '', new Text(0), 'Template message') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $message, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['sms.' . $type . '-' . $locale] = [ - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'message' => $message, - 'type' => $type, - 'locale' => $locale, - ]), Response::MODEL_SMS_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Update custom email templates') ->groups(['api', 'projects']) @@ -1086,7 +952,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -1094,7 +960,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1124,78 +991,6 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -Http::delete('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Reset custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSmsTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON, - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.deleteSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSMSTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($template['sms.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $template['message'] - ]), Response::MODEL_SMS_TEMPLATE); - }); - Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) @@ -1216,11 +1011,12 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - + ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { From dc39af50a12f2b74d75ffbdef0173beb3c667144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:05:46 +0200 Subject: [PATCH 53/96] Support for worldwide fallback custom template for all project emails --- app/controllers/api/account.php | 25 ++++++++++++++----- src/Appwrite/Bus/Listeners/Mails.php | 4 ++- .../Http/Account/MFA/Challenges/Create.php | 8 ++++-- .../Modules/Teams/Http/Memberships/Create.php | 8 ++++-- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0035778523..357c071c85 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2265,7 +2265,10 @@ Http::post('/v1/account/tokens/magic-url') $subject = $locale->getText("emails.magicSession.subject"); $preview = $locale->getText("emails.magicSession.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? []; + + $customTemplate = + $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.magicSession-' . 'worldwide'] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2575,7 +2578,9 @@ Http::post('/v1/account/tokens/email') $preview = $locale->getText("emails.otpSession.preview"); $heading = $locale->getText("emails.otpSession.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.otpSession-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -2968,7 +2973,9 @@ Http::post('/v1/account/tokens/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.login-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } @@ -3726,7 +3733,9 @@ Http::post('/v1/account/recovery') $body = $locale->getText("emails.recovery.body"); $subject = $locale->getText("emails.recovery.subject"); $preview = $locale->getText("emails.recovery.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.recovery-worldwide'] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -4034,7 +4043,9 @@ Http::post('/v1/account/verifications/email') $subject = $locale->getText("emails.verification.subject"); $heading = $locale->getText("emails.verification.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.verification-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -4333,7 +4344,9 @@ Http::post('/v1/account/verifications/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.verification-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 2ffcbc9aa4..7b33baced5 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -71,7 +71,9 @@ class Mails extends Listener throw new \Exception('Invalid template path'); } - $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? []; + $customTemplate = + $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? + $project->getAttribute('templates', [])['email.sessionAlert-worldwide'] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 20a6afed2e..6dc4f024a7 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -170,7 +170,9 @@ class Create extends Action $message = Template::fromFile($templatesPath . '/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.mfaChallenge-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } @@ -223,7 +225,9 @@ class Create extends Action $preview = $locale->getText("emails.mfaChallenge.preview"); $heading = $locale->getText("emails.mfaChallenge.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.mfaChallenge-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5edc69f445..3a8d4460cd 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -324,7 +324,9 @@ class Create extends Action $body = $locale->getText('emails.invitation.body'); $preview = $locale->getText('emails.invitation.preview'); $subject = $locale->getText('emails.invitation.subject'); - $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.invitation-worldwide'] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message @@ -407,7 +409,9 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.invitation-worldwide'] ?? []; if (! empty($customTemplate)) { $message = $customTemplate['message']; } From 0da185e6894d82f6156ca3fe82e20e718206332c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:17:55 +0200 Subject: [PATCH 54/96] Refactor fixes --- app/controllers/api/account.php | 4 +-- app/controllers/api/projects.php | 3 +- app/init/models.php | 2 -- src/Appwrite/SDK/Specification/Format.php | 10 ------ src/Appwrite/Utopia/Response.php | 1 - .../Utopia/Response/Model/TemplateSMS.php | 32 ------------------- 6 files changed, 4 insertions(+), 48 deletions(-) delete mode 100644 src/Appwrite/Utopia/Response/Model/TemplateSMS.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 357c071c85..a913a3bd90 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2267,8 +2267,8 @@ Http::post('/v1/account/tokens/magic-url') $preview = $locale->getText("emails.magicSession.preview"); $customTemplate = - $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.magicSession-' . 'worldwide'] ?? []; + $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 3b83c37acd..0cd5a27c3a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -865,7 +865,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $type . '-' . $locale] + ?? ($locale !== 'worldwide' ? ($templates['email.' . $type . '-worldwide'] ?? null) : null); $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..9aa21e992b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -135,7 +135,6 @@ use Appwrite\Utopia\Response\Model\TemplateFramework; use Appwrite\Utopia\Response\Model\TemplateFunction; use Appwrite\Utopia\Response\Model\TemplateRuntime; use Appwrite\Utopia\Response\Model\TemplateSite; -use Appwrite\Utopia\Response\Model\TemplateSMS; use Appwrite\Utopia\Response\Model\TemplateVariable; use Appwrite\Utopia\Response\Model\Token; use Appwrite\Utopia\Response\Model\Topic; @@ -373,7 +372,6 @@ Response::setModel(new Headers()); Response::setModel(new Specification()); Response::setModel(new Rule()); Response::setModel(new Schedule()); -Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); Response::setModel(new MFAChallenge()); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 91b090a9f6..eb1b45142e 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -595,16 +595,6 @@ abstract class Format return 'EmailTemplateLocale'; } break; - case 'getSmsTemplate': - case 'updateSmsTemplate': - case 'deleteSmsTemplate': - switch ($param) { - case 'type': - return 'SmsTemplateType'; - case 'locale': - return 'SmsTemplateLocale'; - } - break; case 'createPlatform': switch ($param) { case 'type': diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 04d2813e30..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -265,7 +265,6 @@ class Response extends SwooleResponse public const MODEL_VARIABLE = 'variable'; public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; - public const MODEL_SMS_TEMPLATE = 'smsTemplate'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; // Health diff --git a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php b/src/Appwrite/Utopia/Response/Model/TemplateSMS.php deleted file mode 100644 index 2b19ef4878..0000000000 --- a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php +++ /dev/null @@ -1,32 +0,0 @@ - Date: Wed, 15 Apr 2026 18:29:43 +0200 Subject: [PATCH 55/96] More cleanup of sms templates --- app/config/locale/templates.php | 6 ---- .../projects/delete-sms-template.md | 1 - docs/references/projects/get-sms-template.md | 1 - .../projects/update-sms-template.md | 1 - .../Utopia/Response/Model/Template.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 36 ------------------- 6 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 docs/references/projects/delete-sms-template.md delete mode 100644 docs/references/projects/get-sms-template.md delete mode 100644 docs/references/projects/update-sms-template.md diff --git a/app/config/locale/templates.php b/app/config/locale/templates.php index 6aa376678a..680034554b 100644 --- a/app/config/locale/templates.php +++ b/app/config/locale/templates.php @@ -9,11 +9,5 @@ return [ 'mfaChallenge', 'sessionAlert', 'otpSession' - ], - 'sms' => [ - 'verification', - 'login', - 'invitation', - 'mfaChallenge' ] ]; diff --git a/docs/references/projects/delete-sms-template.md b/docs/references/projects/delete-sms-template.md deleted file mode 100644 index c5a7e6cac9..0000000000 --- a/docs/references/projects/delete-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Reset a custom SMS template to its default value. This endpoint removes any custom message and restores the template to its original state. \ No newline at end of file diff --git a/docs/references/projects/get-sms-template.md b/docs/references/projects/get-sms-template.md deleted file mode 100644 index 6ef1d93029..0000000000 --- a/docs/references/projects/get-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a custom SMS template for the specified locale and type returning it's contents. \ No newline at end of file diff --git a/docs/references/projects/update-sms-template.md b/docs/references/projects/update-sms-template.md deleted file mode 100644 index 3e67f613b7..0000000000 --- a/docs/references/projects/update-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Update a custom SMS template for the specified locale and type. Use this endpoint to modify the content of your SMS templates. \ No newline at end of file diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php index 3ce9cacdb3..b0e127e07f 100644 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ b/src/Appwrite/Utopia/Response/Model/Template.php @@ -19,7 +19,7 @@ abstract class Template extends Model 'type' => self::TYPE_STRING, 'description' => 'Template locale', 'default' => '', - 'example' => 'en_us', + 'example' => 'worldwide', ]) ->addRule('message', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 7b9848e38f..597030413e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1161,42 +1161,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); - - // Temporary disabled until implemented - // /** Get Default SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('{{token}}', $response['body']['message']); - - // /** Update SMS template */ - // $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders()), [ - // 'message' => 'Please verify your email {{token}}', - // ]); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); - - // /** Get Updated SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); } public function testUpdateProjectAuthDuration(): void From 2b42487198f122dfae7917753efc6006a4822932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:30:06 +0200 Subject: [PATCH 56/96] Linter fix --- app/controllers/api/account.php | 2 +- app/controllers/api/projects.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a913a3bd90..4ddc9f8e92 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2265,7 +2265,7 @@ Http::post('/v1/account/tokens/magic-url') $subject = $locale->getText("emails.magicSession.subject"); $preview = $locale->getText("emails.magicSession.preview"); - + $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 0cd5a27c3a..6422ebb409 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -857,7 +857,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { $locale = $locale ?? 'worldwide'; - + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1017,7 +1017,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { $locale = $locale ?? 'worldwide'; - + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { From 90e14338787ad1e61f8325651abb1803a71890df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:38:08 +0200 Subject: [PATCH 57/96] Fix agent mistake --- app/controllers/api/projects.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 6422ebb409..411897d170 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -865,8 +865,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] - ?? ($locale !== 'worldwide' ? ($templates['email.' . $type . '-worldwide'] ?? null) : null); + $template = $templates['email.' . $type . '-' . $locale] ?? null; $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); From 590f0636946e9502910caa905a1cd482b6c95f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:40:29 +0200 Subject: [PATCH 58/96] Remove remaining sms leftover --- app/controllers/api/account.php | 14 -------------- .../Account/Http/Account/MFA/Challenges/Create.php | 7 ------- .../Modules/Teams/Http/Memberships/Create.php | 7 ------- 3 files changed, 28 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 4ddc9f8e92..03526bd49f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2973,13 +2973,6 @@ Http::post('/v1/account/tokens/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.login-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $projectName = $project->getAttribute('name'); if ($project->getId() === 'console') { $projectName = $platform['platformName']; @@ -4344,13 +4337,6 @@ Http::post('/v1/account/verifications/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.verification-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $project->getAttribute('name')) diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 6dc4f024a7..319e080f25 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -170,13 +170,6 @@ class Create extends Action $message = Template::fromFile($templatesPath . '/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.mfaChallenge-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $projectName) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 3a8d4460cd..161e817aed 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -409,13 +409,6 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.invitation-worldwide'] ?? []; - if (! empty($customTemplate)) { - $message = $customTemplate['message']; - } - $message = $message->setParam('{{token}}', $url); $message = $message->render(); From 8fd1c5d620c458539b9359ab30b6dab2a6d91c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:54:18 +0200 Subject: [PATCH 59/96] Remove worldwide to not be user-facing --- app/controllers/api/projects.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 411897d170..d4692a7774 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -928,7 +928,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $template['type'] = $type; - $template['locale'] = $locale; + $template['locale'] = $locale === 'worldwide' ? null : $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); @@ -982,7 +982,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale, + 'locale' => $locale === 'worldwide' ? null : $locale, 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, @@ -1036,7 +1036,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale, + 'locale' => $locale === 'worldwide' ? null : $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], From b510194f007c09d8e609c0f5c647a68a77b28958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:57:37 +0200 Subject: [PATCH 60/96] Expose "worldwide" locale --- app/controllers/api/projects.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d4692a7774..dbf608886f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -852,12 +852,13 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -928,7 +929,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $template['type'] = $type; - $template['locale'] = $locale === 'worldwide' ? null : $locale; + $template['locale'] = $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); @@ -961,8 +962,6 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -982,7 +981,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale === 'worldwide' ? null : $locale, + 'locale' => $locale, 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, @@ -1011,12 +1010,13 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1036,7 +1036,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale === 'worldwide' ? null : $locale, + 'locale' => $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], From 6d2876ab268fd04d7d0001c85b2667d921c2781c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 19:01:35 +0200 Subject: [PATCH 61/96] New E2E tests --- .../Projects/ProjectsConsoleClientTest.php | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 597030413e..8fec74b6e3 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,6 +1163,97 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } + #[Group('smtpAndTemplates')] + public function testWorldwideTemplates(): void + { + $data = $this->setupProjectData(); + $id = $data['projectId']; + + /** Get default template without locale (should default to worldwide) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Get default template with explicit worldwide locale */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Set a worldwide email template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'Worldwide verify subject', + 'message' => 'Worldwide verify message {{url}}', + 'senderName' => 'Worldwide Sender', + 'senderEmail' => 'worldwide@appwrite.io', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide verify subject', $response['body']['subject']); + $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); + $this->assertEquals('Worldwide Sender', $response['body']['senderName']); + $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); + $this->assertEquals('verification', $response['body']['type']); + + /** Get the worldwide template back */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide verify subject', $response['body']['subject']); + $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); + $this->assertEquals('Worldwide Sender', $response['body']['senderName']); + $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Locale-specific template should still return default (not worldwide custom) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('en-us', $response['body']['locale']); + // en-us template was not customized, so it should return the default subject + $this->assertEquals('Account Verification for {{project}}', $response['body']['subject']); + + /** Delete the worldwide template */ + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + + /** After deletion, worldwide GET should return default template */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + // Should be back to default (no custom subject) + $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 55001a7daaa5170ca7c497ae0965fc39fc1a782f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 19:27:26 +0200 Subject: [PATCH 62/96] New integration tests --- app/controllers/api/projects.php | 7 +- .../Projects/ProjectsConsoleClientTest.php | 132 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dbf608886f..2163059963 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -953,7 +953,10 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -961,7 +964,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 8fec74b6e3..a6f0c2815a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1254,6 +1254,138 @@ class ProjectsConsoleClientTest extends Scope $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); } + #[Group('smtpAndTemplates')] + public function testWorldwideFallbackOnMagicURL(): void + { + $smtpHost = System::getEnv('_APP_SMTP_HOST', 'maildev'); + $smtpPort = intval(System::getEnv('_APP_SMTP_PORT', '1025')); + $smtpUsername = System::getEnv('_APP_SMTP_USERNAME', 'user'); + $smtpPassword = System::getEnv('_APP_SMTP_PASSWORD', 'password'); + + /** Create a dedicated project for this test */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Worldwide Fallback Test Team', + ]); + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Worldwide Fallback Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** Enable SMTP on the project pointing to maildev */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** Set worldwide magicSession template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'Worldwide Magic Login', + 'message' => 'Worldwide magic link: {{url}}', + 'senderName' => 'Worldwide Mailer', + 'senderEmail' => 'worldwide@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); + + /** Set German (de) magicSession template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'German Magic Login', + 'message' => 'German magic link: {{url}}', + 'senderName' => 'German Mailer', + 'senderEmail' => 'german@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('German Magic Login', $response['body']['subject']); + + /** Trigger magic URL with English locale — should use worldwide fallback */ + $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'en', + ], [ + 'userId' => ID::unique(), + 'email' => $emailEn, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Trigger magic URL with German locale — should use German template */ + $emailDe = 'magic-de-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'de', + ], [ + 'userId' => ID::unique(), + 'email' => $emailDe, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Trigger magic URL with Polish locale — should use worldwide fallback */ + $emailPl = 'magic-pl-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'pl', + ], [ + 'userId' => ID::unique(), + 'email' => $emailPl, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Verify English email uses worldwide fallback template */ + $lastEmailEn = $this->getLastEmailByAddress($emailEn); + $this->assertEquals('Worldwide Magic Login', $lastEmailEn['subject']); + $this->assertEquals('worldwide@appwrite.io', $lastEmailEn['from'][0]['address']); + $this->assertEquals('Worldwide Mailer', $lastEmailEn['from'][0]['name']); + $this->assertStringContainsString('Worldwide magic link:', $lastEmailEn['html']); + + /** Verify German email uses the German-specific template */ + $lastEmailDe = $this->getLastEmailByAddress($emailDe); + $this->assertEquals('German Magic Login', $lastEmailDe['subject']); + $this->assertEquals('german@appwrite.io', $lastEmailDe['from'][0]['address']); + $this->assertEquals('German Mailer', $lastEmailDe['from'][0]['name']); + $this->assertStringContainsString('German magic link:', $lastEmailDe['html']); + + /** Verify Polish email uses worldwide fallback template */ + $lastEmailPl = $this->getLastEmailByAddress($emailPl); + $this->assertEquals('Worldwide Magic Login', $lastEmailPl['subject']); + $this->assertEquals('worldwide@appwrite.io', $lastEmailPl['from'][0]['address']); + $this->assertEquals('Worldwide Mailer', $lastEmailPl['from'][0]['name']); + $this->assertStringContainsString('Worldwide magic link:', $lastEmailPl['html']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 680cb04de792822fda78dc37f2c7d47258ae7a7f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:07:07 +0530 Subject: [PATCH 63/96] feat(specs): add discriminators for polymorphic responses --- app/init/models.php | 6 +- .../Repositories/Detections/Create.php | 1 + .../Http/Installations/Repositories/XList.php | 1 + src/Appwrite/SDK/Specification/Format.php | 111 ++++++++++++++++++ .../SDK/Specification/Format/OpenAPI3.php | 27 +++-- .../SDK/Specification/Format/Swagger2.php | 27 +++-- .../Utopia/Response/Model/Detection.php | 9 +- .../Response/Model/DetectionFramework.php | 6 +- .../Response/Model/DetectionRuntime.php | 6 +- .../Model/ProviderRepositoryFrameworkList.php | 30 +++++ .../Model/ProviderRepositoryRuntimeList.php | 30 +++++ 11 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..c92295ae33 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,7 +117,9 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; +use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; +use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -190,8 +192,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); -Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); +Response::setModel(new ProviderRepositoryFrameworkList()); +Response::setModel(new ProviderRepositoryRuntimeList()); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 5dd5c6dcfa..6295fcd03b 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -307,6 +307,7 @@ class Create extends Action ]; } + $output->setAttribute('type', $type); $output->setAttribute('variables', $variables); $response->dynamic($output, $type === 'framework' ? Response::MODEL_DETECTION_FRAMEWORK : Response::MODEL_DETECTION_RUNTIME); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index d5b2b48175..b4172fabdf 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,6 +313,7 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ + 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 91b090a9f6..f762d2bbaa 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,6 +263,117 @@ abstract class Format return $contents; } + protected function getRegisteredModel(string $type): Model + { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + } + + /** + * @param array $models + * @return array|null + */ + protected function getUnionDiscriminator(array $models, string $refPrefix): ?array + { + if (\count($models) < 2) { + return null; + } + + $candidateKeys = null; + + foreach ($models as $model) { + $keys = []; + + foreach ($model->conditions as $key => $condition) { + if ($this->isDiscriminatorConditionSupported($condition)) { + $keys[] = $key; + } + } + + $candidateKeys = $candidateKeys === null + ? $keys + : \array_values(\array_intersect($candidateKeys, $keys)); + } + + if (empty($candidateKeys)) { + return null; + } + + foreach ($candidateKeys as $key) { + $mapping = []; + $matchedModels = []; + + foreach ($models as $model) { + $rules = $model->getRules(); + + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + continue 2; + } + + $condition = $model->conditions[$key]; + $values = \is_array($condition) ? $condition : [$condition]; + + if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { + $values = \array_values(\array_filter( + $values, + fn (mixed $value) => \in_array($value, $rules[$key]['enum'], true) + )); + } + + if ($values === []) { + continue 2; + } + + foreach ($values as $value) { + $mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + + if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $refPrefix . $model->getType()) { + continue 2; + } + + $mapping[$mappingKey] = $refPrefix . $model->getType(); + } + + $matchedModels[$model->getType()] = true; + } + + if (\count($matchedModels) !== \count($models)) { + continue; + } + + return [ + 'propertyName' => $key, + 'mapping' => $mapping, + ]; + } + + return null; + } + + protected function isDiscriminatorConditionSupported(mixed $condition): bool + { + if (\is_scalar($condition) || \is_bool($condition)) { + return true; + } + + if (!\is_array($condition) || $condition === []) { + return false; + } + + foreach ($condition as $value) { + if (!(\is_scalar($value) || \is_bool($value))) { + return false; + } + } + + return true; + } + protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index b611558826..c5af43f64d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -316,9 +316,10 @@ class OpenAPI3 extends Format 'description' => $modelDescription, 'content' => [ $produces => [ - 'schema' => [ - 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model) - ], + 'schema' => \array_filter([ + 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), + 'discriminator' => $this->getUnionDiscriminator($model, '#/components/schemas/'), + ]), ], ], ]; @@ -901,17 +902,25 @@ class OpenAPI3 extends Format if (\is_array($rule['type'])) { if ($rule['array']) { - $items = [ + $items = \array_filter([ 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/components/schemas/' + ), + ]); } else { - $items = [ + $items = \array_filter([ 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/components/schemas/' + ), + ]); } } else { $items = [ diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 413239f000..8e9a39a3c1 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,11 +322,12 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => [ + 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model) - ], + }, $model), + 'x-discriminator' => $this->getUnionDiscriminator($model, '#/definitions/'), + ]), ]; } else { // Response definition using one type @@ -881,13 +882,21 @@ class Swagger2 extends Format if (\is_array($rule['type'])) { if ($rule['array']) { - $items = [ - 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/definitions/' + ), + ]); } else { - $items = [ - 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/definitions/' + ), + ]); } } else { $items = [ diff --git a/src/Appwrite/Utopia/Response/Model/Detection.php b/src/Appwrite/Utopia/Response/Model/Detection.php index 007182d1e9..d57e4a27c0 100644 --- a/src/Appwrite/Utopia/Response/Model/Detection.php +++ b/src/Appwrite/Utopia/Response/Model/Detection.php @@ -7,9 +7,16 @@ use Appwrite\Utopia\Response\Model; abstract class Detection extends Model { - public function __construct() + public function __construct(string $type) { $this + ->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => $type, + 'example' => $type, + 'enum' => ['runtime', 'framework'], + ]) ->addRule('variables', [ 'type' => Response::MODEL_DETECTION_VARIABLE, 'description' => 'Environment variables found in .env files', diff --git a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php index 4cdf37bbcf..00f318ba4a 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php @@ -8,7 +8,11 @@ class DetectionFramework extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'framework', + ]; + + parent::__construct('framework'); $this ->addRule('framework', [ diff --git a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php index 1e63929092..94368f890c 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php @@ -8,7 +8,11 @@ class DetectionRuntime extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'runtime', + ]; + + parent::__construct('runtime'); $this ->addRule('runtime', [ diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php new file mode 100644 index 0000000000..9816ce806e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -0,0 +1,30 @@ + 'framework', + ]; + + public function __construct() + { + parent::__construct( + 'Framework Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + 'frameworkProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK + ); + + $this->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => 'framework', + 'example' => 'framework', + 'enum' => ['runtime', 'framework'], + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php new file mode 100644 index 0000000000..a30fa4d3b5 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -0,0 +1,30 @@ + 'runtime', + ]; + + public function __construct() + { + parent::__construct( + 'Runtime Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + 'runtimeProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME + ); + + $this->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => 'runtime', + 'example' => 'runtime', + 'enum' => ['runtime', 'framework'], + ]); + } +} From 6a7280e7dddb162b9cc994dec9aa462a57551fd8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:12:43 +0530 Subject: [PATCH 64/96] refactor(specs): inline discriminator condition checks --- src/Appwrite/SDK/Specification/Format.php | 47 ++++++++++------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index f762d2bbaa..76e8bc8678 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -287,13 +287,7 @@ abstract class Format $candidateKeys = null; foreach ($models as $model) { - $keys = []; - - foreach ($model->conditions as $key => $condition) { - if ($this->isDiscriminatorConditionSupported($condition)) { - $keys[] = $key; - } - } + $keys = \array_keys($model->conditions); $candidateKeys = $candidateKeys === null ? $keys @@ -316,7 +310,25 @@ abstract class Format } $condition = $model->conditions[$key]; - $values = \is_array($condition) ? $condition : [$condition]; + if (!\is_array($condition)) { + if (!\is_scalar($condition)) { + continue 2; + } + + $values = [$condition]; + } else { + if ($condition === []) { + continue 2; + } + + $values = $condition; + + foreach ($values as $value) { + if (!\is_scalar($value)) { + continue 3; + } + } + } if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { $values = \array_values(\array_filter( @@ -355,25 +367,6 @@ abstract class Format return null; } - protected function isDiscriminatorConditionSupported(mixed $condition): bool - { - if (\is_scalar($condition) || \is_bool($condition)) { - return true; - } - - if (!\is_array($condition) || $condition === []) { - return false; - } - - foreach ($condition as $value) { - if (!(\is_scalar($value) || \is_bool($value))) { - return false; - } - } - - return true; - } - protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ From a0db02386088d162ebacdd2f17e99a9fe1151696 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:15:08 +0530 Subject: [PATCH 65/96] refactor(specs): simplify discriminator resolution --- src/Appwrite/SDK/Specification/Format.php | 52 ++++++++++++------- .../SDK/Specification/Format/OpenAPI3.php | 6 +-- .../SDK/Specification/Format/Swagger2.php | 6 +-- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 76e8bc8678..47c683b358 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -278,20 +278,16 @@ abstract class Format * @param array $models * @return array|null */ - protected function getUnionDiscriminator(array $models, string $refPrefix): ?array + protected function getDisciminator(array $models, string $refPrefix): ?array { if (\count($models) < 2) { return null; } - $candidateKeys = null; + $candidateKeys = \array_keys($models[0]->conditions); - foreach ($models as $model) { - $keys = \array_keys($model->conditions); - - $candidateKeys = $candidateKeys === null - ? $keys - : \array_values(\array_intersect($candidateKeys, $keys)); + foreach (\array_slice($models, 1) as $model) { + $candidateKeys = \array_values(\array_intersect($candidateKeys, \array_keys($model->conditions))); } if (empty($candidateKeys)) { @@ -300,34 +296,44 @@ abstract class Format foreach ($candidateKeys as $key) { $mapping = []; - $matchedModels = []; + $isValid = true; foreach ($models as $model) { $rules = $model->getRules(); + $condition = $model->conditions[$key] ?? null; if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { - continue 2; + $isValid = false; + break; } - $condition = $model->conditions[$key]; if (!\is_array($condition)) { if (!\is_scalar($condition)) { - continue 2; + $isValid = false; + break; } $values = [$condition]; } else { if ($condition === []) { - continue 2; + $isValid = false; + break; } $values = $condition; + $hasInvalidValue = false; foreach ($values as $value) { if (!\is_scalar($value)) { - continue 3; + $hasInvalidValue = true; + break; } } + + if ($hasInvalidValue) { + $isValid = false; + break; + } } if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { @@ -338,23 +344,29 @@ abstract class Format } if ($values === []) { - continue 2; + $isValid = false; + break; } + $ref = $refPrefix . $model->getType(); + foreach ($values as $value) { $mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; - if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $refPrefix . $model->getType()) { - continue 2; + if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $ref) { + $isValid = false; + break; } - $mapping[$mappingKey] = $refPrefix . $model->getType(); + $mapping[$mappingKey] = $ref; } - $matchedModels[$model->getType()] = true; + if (!$isValid) { + break; + } } - if (\count($matchedModels) !== \count($models)) { + if (!$isValid || $mapping === []) { continue; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index c5af43f64d..bb9451ff4d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -318,7 +318,7 @@ class OpenAPI3 extends Format $produces => [ 'schema' => \array_filter([ 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), - 'discriminator' => $this->getUnionDiscriminator($model, '#/components/schemas/'), + 'discriminator' => $this->getDisciminator($model, '#/components/schemas/'), ]), ], ], @@ -906,7 +906,7 @@ class OpenAPI3 extends Format 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getUnionDiscriminator( + 'discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/components/schemas/' ), @@ -916,7 +916,7 @@ class OpenAPI3 extends Format 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getUnionDiscriminator( + 'discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/components/schemas/' ), diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 8e9a39a3c1..5258fc8b7c 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -326,7 +326,7 @@ class Swagger2 extends Format 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; }, $model), - 'x-discriminator' => $this->getUnionDiscriminator($model, '#/definitions/'), + 'x-discriminator' => $this->getDisciminator($model, '#/definitions/'), ]), ]; } else { @@ -884,7 +884,7 @@ class Swagger2 extends Format if ($rule['array']) { $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getUnionDiscriminator( + 'x-discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/definitions/' ), @@ -892,7 +892,7 @@ class Swagger2 extends Format } else { $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getUnionDiscriminator( + 'x-discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/definitions/' ), From 945cdb3a99080fb8700e656ce4162c03b88c8fb4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:16:25 +0530 Subject: [PATCH 66/96] refactor(specs): inline model resolution --- src/Appwrite/SDK/Specification/Format.php | 11 ---------- .../SDK/Specification/Format/OpenAPI3.php | 20 +++++++++++++++++-- .../SDK/Specification/Format/Swagger2.php | 20 +++++++++++++++++-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 47c683b358..b60d16274f 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,17 +263,6 @@ abstract class Format return $contents; } - protected function getRegisteredModel(string $type): Model - { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - } - /** * @param array $models * @return array|null diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index bb9451ff4d..72dac7064a 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -907,7 +907,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); @@ -917,7 +925,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 5258fc8b7c..46280152e4 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -885,7 +885,15 @@ class Swagger2 extends Format $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), 'x-discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/definitions/' ), ]); @@ -893,7 +901,15 @@ class Swagger2 extends Format $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), 'x-discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/definitions/' ), ]); From b71d42d226610cbbb0dd6857538aee764ad87d11 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:29:16 +0530 Subject: [PATCH 67/96] fix(specs): rename getDisciminator typo and extract shared model resolution Fix misspelled method name (getDisciminator -> getDiscriminator) across Format, OpenAPI3, and Swagger2. Extract duplicated model-resolution lambda into Format::resolveModels(). Fix copy-pasted descriptions in ProviderRepository list models. --- src/Appwrite/SDK/Specification/Format.php | 19 +++++++++++++- .../SDK/Specification/Format/OpenAPI3.php | 26 ++++--------------- .../SDK/Specification/Format/Swagger2.php | 26 ++++--------------- .../Model/ProviderRepositoryFrameworkList.php | 2 +- .../Model/ProviderRepositoryRuntimeList.php | 2 +- 5 files changed, 30 insertions(+), 45 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index b60d16274f..2cf2759054 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,11 +263,28 @@ abstract class Format return $contents; } + /** + * @param array $types + * @return array + */ + protected function resolveModels(array $types): array + { + return \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $types); + } + /** * @param array $models * @return array|null */ - protected function getDisciminator(array $models, string $refPrefix): ?array + protected function getDiscriminator(array $models, string $refPrefix): ?array { if (\count($models) < 2) { return null; diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 72dac7064a..76fcdf9a4d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -318,7 +318,7 @@ class OpenAPI3 extends Format $produces => [ 'schema' => \array_filter([ 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), - 'discriminator' => $this->getDisciminator($model, '#/components/schemas/'), + 'discriminator' => $this->getDiscriminator($model, '#/components/schemas/'), ]), ], ], @@ -906,16 +906,8 @@ class OpenAPI3 extends Format 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/components/schemas/' ), ]); @@ -924,16 +916,8 @@ class OpenAPI3 extends Format 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/components/schemas/' ), ]); diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 46280152e4..2ca24dd921 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -326,7 +326,7 @@ class Swagger2 extends Format 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; }, $model), - 'x-discriminator' => $this->getDisciminator($model, '#/definitions/'), + 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), ]), ]; } else { @@ -884,32 +884,16 @@ class Swagger2 extends Format if ($rule['array']) { $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'x-discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/definitions/' ), ]); } else { $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'x-discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/definitions/' ), ]); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php index 9816ce806e..4562b175a4 100644 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -21,7 +21,7 @@ class ProviderRepositoryFrameworkList extends BaseList $this->addRule('type', [ 'type' => self::TYPE_ENUM, - 'description' => 'Repository detection type.', + 'description' => 'Provider repository list type.', 'default' => 'framework', 'example' => 'framework', 'enum' => ['runtime', 'framework'], diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php index a30fa4d3b5..f2617d46f6 100644 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -21,7 +21,7 @@ class ProviderRepositoryRuntimeList extends BaseList $this->addRule('type', [ 'type' => self::TYPE_ENUM, - 'description' => 'Repository detection type.', + 'description' => 'Provider repository list type.', 'default' => 'runtime', 'example' => 'runtime', 'enum' => ['runtime', 'framework'], From 4545989c912f9debb000e1f3d43ad9021bba4648 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 12:22:37 +0530 Subject: [PATCH 68/96] fix(specs): remove type rule from list models, keep only on specific models --- app/init/models.php | 6 ++-- .../Http/Installations/Repositories/XList.php | 1 - .../Model/ProviderRepositoryFrameworkList.php | 30 ------------------- .../Model/ProviderRepositoryRuntimeList.php | 30 ------------------- 4 files changed, 2 insertions(+), 65 deletions(-) delete mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php delete mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php diff --git a/app/init/models.php b/app/init/models.php index c92295ae33..dd97b03652 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,9 +117,7 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; -use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; -use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -192,8 +190,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new ProviderRepositoryFrameworkList()); -Response::setModel(new ProviderRepositoryRuntimeList()); +Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); +Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index b4172fabdf..d5b2b48175 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,7 +313,6 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ - 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php deleted file mode 100644 index 4562b175a4..0000000000 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php +++ /dev/null @@ -1,30 +0,0 @@ - 'framework', - ]; - - public function __construct() - { - parent::__construct( - 'Framework Provider Repositories List', - Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, - 'frameworkProviderRepositories', - Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK - ); - - $this->addRule('type', [ - 'type' => self::TYPE_ENUM, - 'description' => 'Provider repository list type.', - 'default' => 'framework', - 'example' => 'framework', - 'enum' => ['runtime', 'framework'], - ]); - } -} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php deleted file mode 100644 index f2617d46f6..0000000000 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php +++ /dev/null @@ -1,30 +0,0 @@ - 'runtime', - ]; - - public function __construct() - { - parent::__construct( - 'Runtime Provider Repositories List', - Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, - 'runtimeProviderRepositories', - Response::MODEL_PROVIDER_REPOSITORY_RUNTIME - ); - - $this->addRule('type', [ - 'type' => self::TYPE_ENUM, - 'description' => 'Provider repository list type.', - 'default' => 'runtime', - 'example' => 'runtime', - 'enum' => ['runtime', 'framework'], - ]); - } -} From 965836c8b4e7c5f5e9bea93ab3cb99944926b75c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 12:28:53 +0530 Subject: [PATCH 69/96] fix(specs): use swagger discriminator extension mapping --- src/Appwrite/SDK/Specification/Format.php | 17 ------- .../SDK/Specification/Format/OpenAPI3.php | 20 +++++++- .../SDK/Specification/Format/Swagger2.php | 51 ++++++++++++++----- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 2cf2759054..8cdb3ee5c3 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,23 +263,6 @@ abstract class Format return $contents; } - /** - * @param array $types - * @return array - */ - protected function resolveModels(array $types): array - { - return \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $types); - } - /** * @param array $models * @return array|null diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 76fcdf9a4d..84d99fc2f6 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -907,7 +907,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); @@ -917,7 +925,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 2ca24dd921..eede2183f0 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,12 +322,17 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => \array_filter([ - 'x-oneOf' => \array_map(function ($m) { - return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model), - 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), - ]), + 'schema' => (function () use ($model) { + $discriminator = $this->getDiscriminator($model, '#/definitions/'); + + return \array_filter([ + 'x-oneOf' => \array_map(function ($m) { + return ['$ref' => '#/definitions/' . $m->getType()]; + }, $model), + 'discriminator' => $discriminator['propertyName'] ?? null, + 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, + ]); + })(), ]; } else { // Response definition using one type @@ -882,20 +887,38 @@ class Swagger2 extends Format if (\is_array($rule['type'])) { if ($rule['array']) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), - '#/definitions/' - ), + 'discriminator' => $discriminator['propertyName'] ?? null, + 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, ]); } else { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), - '#/definitions/' - ), + 'discriminator' => $discriminator['propertyName'] ?? null, + 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, ]); } } else { From 1493b7b8a6eb62e3852a183d75a628858c6262ea Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:02:57 +0530 Subject: [PATCH 70/96] feat(specs): unified discriminator with compound support and algo conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify getDiscriminator to produce a single discriminator object for both single-key and compound cases. Single-key returns standard {propertyName, mapping}. Compound falls back to extending the object with x-propertyNames and x-mapping for multi-property discrimination. Simplify call sites: OpenAPI3 uses 'discriminator', Swagger2 uses 'x-discriminator' — no more split keys. Add conditions to all 7 Algo models (AlgoArgon2, AlgoBcrypt, AlgoMd5, AlgoPhpass, AlgoScrypt, AlgoScryptModified, AlgoSha) to enable discriminator generation for hashOptions unions. --- src/Appwrite/SDK/Specification/Format.php | 73 ++++++++++++++++++- .../SDK/Specification/Format/OpenAPI3.php | 36 +++------ .../SDK/Specification/Format/Swagger2.php | 52 +++++-------- .../Utopia/Response/Model/AlgoArgon2.php | 4 + .../Utopia/Response/Model/AlgoBcrypt.php | 4 + .../Utopia/Response/Model/AlgoMd5.php | 4 + .../Utopia/Response/Model/AlgoPhpass.php | 4 + .../Utopia/Response/Model/AlgoScrypt.php | 4 + .../Response/Model/AlgoScryptModified.php | 4 + .../Utopia/Response/Model/AlgoSha.php | 4 + 10 files changed, 129 insertions(+), 60 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 8cdb3ee5c3..ce1eb97203 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -365,7 +365,78 @@ abstract class Format ]; } - return null; + // Single-key failed — try compound discriminator + return $this->getCompoundDiscriminator($models, $refPrefix); + } + + /** + * @param array $models + * @return array|null + */ + private function getCompoundDiscriminator(array $models, string $refPrefix): ?array + { + $allKeys = []; + foreach ($models as $model) { + foreach (\array_keys($model->conditions) as $key) { + if (!\in_array($key, $allKeys, true)) { + $allKeys[] = $key; + } + } + } + + if (\count($allKeys) < 2) { + return null; + } + + $primaryKey = $allKeys[0]; + $primaryMapping = []; + $compoundMapping = []; + + foreach ($models as $model) { + $rules = $model->getRules(); + $conditions = []; + + foreach ($model->conditions as $key => $condition) { + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + return null; + } + + if (!\is_scalar($condition)) { + return null; + } + + $conditions[$key] = \is_bool($condition) ? ($condition ? 'true' : 'false') : (string) $condition; + } + + if (empty($conditions)) { + return null; + } + + $ref = $refPrefix . $model->getType(); + $compoundMapping[$ref] = $conditions; + + // Best-effort single-key mapping — last model with this value wins (fallback) + if (isset($conditions[$primaryKey])) { + $primaryMapping[$conditions[$primaryKey]] = $ref; + } + } + + // Verify compound uniqueness + $seen = []; + foreach ($compoundMapping as $conditions) { + $sig = \json_encode($conditions, JSON_THROW_ON_ERROR); + if (isset($seen[$sig])) { + return null; + } + $seen[$sig] = true; + } + + return \array_filter([ + 'propertyName' => $primaryKey, + 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, + 'x-propertyNames' => $allKeys, + 'x-mapping' => $compoundMapping, + ]); } protected function getRequestEnumName(string $service, string $method, string $param): ?string diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 84d99fc2f6..fcff6ac2f4 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -901,41 +901,29 @@ class OpenAPI3 extends Format $rule['type'] = ($rule['type']) ? $rule['type'] : 'none'; if (\is_array($rule['type'])) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + if ($rule['array']) { $items = \array_filter([ 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDiscriminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), - '#/components/schemas/' - ), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), ]); } else { $items = \array_filter([ 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDiscriminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), - '#/components/schemas/' - ), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), ]); } } else { diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index eede2183f0..8d47766117 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,17 +322,12 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => (function () use ($model) { - $discriminator = $this->getDiscriminator($model, '#/definitions/'); - - return \array_filter([ - 'x-oneOf' => \array_map(function ($m) { - return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model), - 'discriminator' => $discriminator['propertyName'] ?? null, - 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, - ]); - })(), + 'schema' => \array_filter([ + 'x-oneOf' => \array_map(function ($m) { + return ['$ref' => '#/definitions/' . $m->getType()]; + }, $model), + 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), + ]), ]; } else { // Response definition using one type @@ -886,39 +881,26 @@ class Swagger2 extends Format $rule['type'] = ($rule['type']) ?: 'none'; if (\is_array($rule['type'])) { - if ($rule['array']) { - $resolvedModels = \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; } + } - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']); - $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $xDiscriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + if ($rule['array']) { $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'discriminator' => $discriminator['propertyName'] ?? null, - 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, + 'x-discriminator' => $xDiscriminator, ]); } else { - $resolvedModels = \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']); - $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); - $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'discriminator' => $discriminator['propertyName'] ?? null, - 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, + 'x-discriminator' => $xDiscriminator, ]); } } else { diff --git a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php index 3e162bb905..a721235f94 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoArgon2 extends Model { + public array $conditions = [ + 'type' => 'argon2', + ]; + public function __construct() { // No options if imported. If hashed by Appwrite, following configuration is available: diff --git a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php index 709dea1a41..ef15e5d50a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoBcrypt extends Model { + public array $conditions = [ + 'type' => 'bcrypt', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php index 509ee70c31..26b2886330 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoMd5 extends Model { + public array $conditions = [ + 'type' => 'md5', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php index f16792086e..7d8400edec 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoPhpass extends Model { + public array $conditions = [ + 'type' => 'phpass', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php index 4dda297d71..043a27166d 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScrypt extends Model { + public array $conditions = [ + 'type' => 'scrypt', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php index 40b9df1dad..24dd41bb77 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScryptModified extends Model { + public array $conditions = [ + 'type' => 'scryptMod', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoSha.php b/src/Appwrite/Utopia/Response/Model/AlgoSha.php index 2a0893adc4..52743ec26a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoSha.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoSha.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoSha extends Model { + public array $conditions = [ + 'type' => 'sha', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration From 6dc17c91bce78310ab4f2bd58a8bb0fd537b220c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:08:14 +0530 Subject: [PATCH 71/96] trigger greptile From 98ec9e45c4e004ce5896e92003fd008a9fa8caf8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:16:13 +0530 Subject: [PATCH 72/96] fix(specs): narrow Detection type enum to each subclass's own value Each Detection subclass now declares only its own type value in the enum rather than sharing the full ['runtime', 'framework'] list. This prevents SDK validators from accepting invalid values on concrete models. --- src/Appwrite/Utopia/Response/Model/Detection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Detection.php b/src/Appwrite/Utopia/Response/Model/Detection.php index d57e4a27c0..9dfcc795d6 100644 --- a/src/Appwrite/Utopia/Response/Model/Detection.php +++ b/src/Appwrite/Utopia/Response/Model/Detection.php @@ -15,7 +15,7 @@ abstract class Detection extends Model 'description' => 'Repository detection type.', 'default' => $type, 'example' => $type, - 'enum' => ['runtime', 'framework'], + 'enum' => [$type], ]) ->addRule('variables', [ 'type' => Response::MODEL_DETECTION_VARIABLE, From 05d70f8826228502e4d919c256da610254a0d3ba Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:32:05 +0530 Subject: [PATCH 73/96] refactor(specs): rename x-propertyNames/x-mapping to x-discriminator-properties/x-union-typemap --- src/Appwrite/SDK/Specification/Format.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index ce1eb97203..d01ba20ae3 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -434,8 +434,8 @@ abstract class Format return \array_filter([ 'propertyName' => $primaryKey, 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, - 'x-propertyNames' => $allKeys, - 'x-mapping' => $compoundMapping, + 'x-discriminator-properties' => $allKeys, + 'x-union-typemap' => $compoundMapping, ]); } From 19d0eb66c019a507b39dc23b5258d983746b320a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:09:38 +0200 Subject: [PATCH 74/96] Fix tests --- app/controllers/api/projects.php | 5 ++- .../Projects/ProjectsConsoleClientTest.php | 40 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 2163059963..60b6f5d770 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -868,8 +868,9 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + $fallbackLocale = System::getEnv('_APP_LOCALE', 'en'); + $localeObj = new Locale($locale === 'worldwide' ? $fallbackLocale : $locale); + $localeObj->setFallback($fallbackLocale); if (is_null($template)) { /** diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index a6f0c2815a..78b7661ab2 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1169,16 +1169,6 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectData(); $id = $data['projectId']; - /** Get default template without locale (should default to worldwide) */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - /** Get default template with explicit worldwide locale */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ 'content-type' => 'application/json', @@ -1221,7 +1211,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('worldwide', $response['body']['locale']); - /** Locale-specific template should still return default (not worldwide custom) */ + /** Locale-specific template should not return the worldwide custom template */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1230,8 +1220,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); - // en-us template was not customized, so it should return the default subject - $this->assertEquals('Account Verification for {{project}}', $response['body']['subject']); + // en-us should NOT return the worldwide custom subject + $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); /** Delete the worldwide template */ $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ @@ -1325,6 +1315,30 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('German Magic Login', $response['body']['subject']); + /** Verify worldwide template is stored correctly */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); + + /** Verify German template is stored correctly */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('German Magic Login', $response['body']['subject']); + + /** Verify SMTP is enabled on the project */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['smtpEnabled']); + /** Trigger magic URL with English locale — should use worldwide fallback */ $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ From 4cf375de6d35c9625730fe26b385b0217fc25ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:17:08 +0200 Subject: [PATCH 75/96] Re-add removed test --- app/controllers/api/projects.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 60b6f5d770..46806f79ed 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -858,7 +858,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 78b7661ab2..e4dfb8d85b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1169,6 +1169,16 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectData(); $id = $data['projectId']; + /** Get default template without locale (should default to worldwide) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + /** Get default template with explicit worldwide locale */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ 'content-type' => 'application/json', @@ -1247,10 +1257,10 @@ class ProjectsConsoleClientTest extends Scope #[Group('smtpAndTemplates')] public function testWorldwideFallbackOnMagicURL(): void { - $smtpHost = System::getEnv('_APP_SMTP_HOST', 'maildev'); - $smtpPort = intval(System::getEnv('_APP_SMTP_PORT', '1025')); - $smtpUsername = System::getEnv('_APP_SMTP_USERNAME', 'user'); - $smtpPassword = System::getEnv('_APP_SMTP_PASSWORD', 'password'); + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; /** Create a dedicated project for this test */ $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ From e472d98fe39a8661be61ca80e57a3e0ed55993fe Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:55:36 +0530 Subject: [PATCH 76/96] Revert "refactor(specs): rename x-propertyNames/x-mapping to x-discriminator-properties/x-union-typemap" This reverts commit 05d70f8826228502e4d919c256da610254a0d3ba. --- src/Appwrite/SDK/Specification/Format.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index d01ba20ae3..ce1eb97203 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -434,8 +434,8 @@ abstract class Format return \array_filter([ 'propertyName' => $primaryKey, 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, - 'x-discriminator-properties' => $allKeys, - 'x-union-typemap' => $compoundMapping, + 'x-propertyNames' => $allKeys, + 'x-mapping' => $compoundMapping, ]); } From 807e8bec8be12af10e9e9af01d004d6cf68420df Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 16:29:42 +0530 Subject: [PATCH 77/96] feat(specs): add discriminator for provider repository list response union Add ProviderRepositoryFrameworkList and ProviderRepositoryRuntimeList model classes with conditions and type field so the listRepositories endpoint's oneOf response gets a discriminator on the type property. --- app/init/models.php | 6 ++-- .../Http/Installations/Repositories/XList.php | 1 + .../Model/ProviderRepositoryFrameworkList.php | 29 +++++++++++++++++++ .../Model/ProviderRepositoryRuntimeList.php | 29 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..c92295ae33 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,7 +117,9 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; +use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; +use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -190,8 +192,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); -Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); +Response::setModel(new ProviderRepositoryFrameworkList()); +Response::setModel(new ProviderRepositoryRuntimeList()); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index d5b2b48175..b4172fabdf 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,6 +313,7 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ + 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php new file mode 100644 index 0000000000..d1982e2f84 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -0,0 +1,29 @@ + 'framework', + ]; + + public function __construct() + { + parent::__construct( + 'Framework Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + 'frameworkProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'framework', + 'example' => 'framework', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php new file mode 100644 index 0000000000..f7ef1d7b5f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -0,0 +1,29 @@ + 'runtime', + ]; + + public function __construct() + { + parent::__construct( + 'Runtime Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + 'runtimeProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'runtime', + 'example' => 'runtime', + ]); + } +} From 463e5acf5069bf2331f2b95321f2fad6e4d84c42 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Thu, 16 Apr 2026 16:57:19 +0530 Subject: [PATCH 78/96] compose fixes --- app/views/install/compose.phtml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index ef4d4a1fe4..1bf36b7f6d 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -120,7 +120,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_SMTP_HOST - _APP_SMTP_PORT - _APP_SMTP_SECURE @@ -256,7 +255,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_USAGE_STATS - _APP_LOGGING_CONFIG @@ -287,7 +285,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-webhooks: @@ -315,7 +312,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -356,7 +352,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_STORAGE_DEVICE - _APP_STORAGE_S3_ACCESS_KEY - _APP_STORAGE_S3_SECRET @@ -416,7 +411,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-builds: @@ -453,7 +447,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_VCS_GITHUB_APP_NAME - _APP_VCS_GITHUB_PRIVATE_KEY @@ -529,7 +522,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-executions: @@ -592,7 +584,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_FUNCTIONS_TIMEOUT - _APP_SITES_TIMEOUT - _APP_COMPUTE_BUILD_TIMEOUT @@ -630,7 +621,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -673,7 +663,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_SMS_FROM - _APP_SMS_PROVIDER @@ -734,7 +723,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET @@ -773,7 +761,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_MAINTENANCE_INTERVAL - _APP_MAINTENANCE_RETENTION_EXECUTION - _APP_MAINTENANCE_RETENTION_CACHE @@ -806,7 +793,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -839,7 +825,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -871,7 +856,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -907,7 +891,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-executions: image: /: @@ -936,7 +919,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-messages: image: /: @@ -965,7 +947,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-assistant: @@ -1068,13 +1049,12 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging + restart: unless-stopped networks: - appwrite volumes: - appwrite-mongodb:/data/db - appwrite-mongodb-keyfile:/data/keyfile - ports: - - "27017:27017" environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=${_APP_DB_ROOT_PASS} @@ -1205,7 +1185,6 @@ volumes: appwrite-mongodb: appwrite-mongodb-keyfile: - appwrite-mongodb-config: appwrite-redis: appwrite-cache: From 1e797b3f01b005d5a385ecf85e61646b0f7b2634 Mon Sep 17 00:00:00 2001 From: Aditya Oberai Date: Thu, 16 Apr 2026 17:00:28 +0000 Subject: [PATCH 79/96] Update React Admin template metadata --- app/config/templates/site.php | 12 ++++++------ ...dmin-dark.png => dashboard-react-admin-dark.png} | Bin ...in-light.png => dashboard-react-admin-light.png} | Bin 3 files changed, 6 insertions(+), 6 deletions(-) rename public/images/sites/templates/{crm-dashboard-react-admin-dark.png => dashboard-react-admin-dark.png} (100%) rename public/images/sites/templates/{crm-dashboard-react-admin-light.png => dashboard-react-admin-light.png} (100%) diff --git a/app/config/templates/site.php b/app/config/templates/site.php index 26f8e39817..b26d31f475 100644 --- a/app/config/templates/site.php +++ b/app/config/templates/site.php @@ -1487,13 +1487,13 @@ return [ ] ], [ - 'key' => 'crm-dashboard-react-admin', - 'name' => 'CRM dashboard with React Admin', - 'tagline' => 'A React-based admin dashboard template with CRM features.', + 'key' => 'dashboard-react-admin', + 'name' => 'E-commerce dashboard with React Admin', + 'tagline' => 'A React-based admin dashboard template with e-commerce features.', 'score' => 4, // 0 to 10 based on looks of screenshot (avoid 1,2,3,8,9,10 if possible) - 'useCases' => [SiteUseCases::DASHBOARD], - 'screenshotDark' => $url . '/images/sites/templates/crm-dashboard-react-admin-dark.png', - 'screenshotLight' => $url . '/images/sites/templates/crm-dashboard-react-admin-light.png', + 'useCases' => [SiteUseCases::DASHBOARD, SiteUseCases::ECOMMERCE], + 'screenshotDark' => $url . '/images/sites/templates/dashboard-react-admin-dark.png', + 'screenshotLight' => $url . '/images/sites/templates/dashboard-react-admin-light.png', 'frameworks' => [ getFramework('REACT', [ 'providerRootDirectory' => './react/react-admin', diff --git a/public/images/sites/templates/crm-dashboard-react-admin-dark.png b/public/images/sites/templates/dashboard-react-admin-dark.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-dark.png rename to public/images/sites/templates/dashboard-react-admin-dark.png diff --git a/public/images/sites/templates/crm-dashboard-react-admin-light.png b/public/images/sites/templates/dashboard-react-admin-light.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-light.png rename to public/images/sites/templates/dashboard-react-admin-light.png From 71b74e21a36773a71c3c8ea8f28525a6ca636574 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 13:36:48 +0530 Subject: [PATCH 80/96] added delay metric --- app/init/constants.php | 1 + app/realtime.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..12de293ff6 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -410,6 +410,7 @@ const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; const METRIC_REALTIME_INBOUND = 'realtime.inbound'; const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; +const METRIC_REALTIME_DELIVERY_DELAY = 'realtime.delivery.delay'; // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; diff --git a/app/realtime.php b/app/realtime.php index 955832e93a..bbff9b98ab 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -600,6 +600,24 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, ]; + $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; + if (\is_string($updatedAt)) { + try { + $updatedAtDate = new \DateTimeImmutable($updatedAt); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $delayMs = (int) \max( + 0, + $nowTimestampMs - $updatedAtTimestampMs + ); + + $metrics[METRIC_REALTIME_DELIVERY_DELAY] = $delayMs; + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } + if ($outboundBytes > 0) { $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; } From b5ec92964c28b98bc554f87cf4b133fe82361f54 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 14:08:42 +0530 Subject: [PATCH 81/96] updated telemetry --- app/init/constants.php | 1 - app/realtime.php | 33 +++++++++++++++------------------ 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 12de293ff6..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -410,7 +410,6 @@ const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; const METRIC_REALTIME_INBOUND = 'realtime.inbound'; const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; -const METRIC_REALTIME_DELIVERY_DELAY = 'realtime.delivery.delay'; // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; diff --git a/app/realtime.php b/app/realtime.php index bbff9b98ab..192672a2f3 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -398,6 +398,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); + $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram('realtime.server.delivery_delay', 'ms')); $attempts = 0; $start = time(); @@ -592,6 +593,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); $stats->incr($event['project'], 'messages', $total); + $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; + if (\is_string($updatedAt)) { + try { + $updatedAtDate = new \DateTimeImmutable($updatedAt); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $delayMs = (int) \max(0, $nowTimestampMs - $updatedAtTimestampMs); + + $register->get('telemetry.deliveryDelayHistogram')->record($delayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } $projectId = $event['project'] ?? null; @@ -600,24 +615,6 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, ]; - $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; - if (\is_string($updatedAt)) { - try { - $updatedAtDate = new \DateTimeImmutable($updatedAt); - $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); - $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; - $nowTimestampMs = (float) $now->format('U.u') * 1000; - $delayMs = (int) \max( - 0, - $nowTimestampMs - $updatedAtTimestampMs - ); - - $metrics[METRIC_REALTIME_DELIVERY_DELAY] = $delayMs; - } catch (\Throwable) { - // Ignore invalid timestamp payloads. - } - } - if ($outboundBytes > 0) { $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; } From 11f23fdcfa3aa90cd862609e869ca430fb14f209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 10:52:21 +0200 Subject: [PATCH 82/96] Rework email templates PR after discussions --- app/controllers/api/account.php | 8 +- app/controllers/api/projects.php | 35 ++- src/Appwrite/Bus/Listeners/Mails.php | 2 +- .../Http/Account/MFA/Challenges/Create.php | 2 +- .../Modules/Teams/Http/Memberships/Create.php | 2 +- .../Utopia/Response/Model/Template.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 247 ------------------ 7 files changed, 25 insertions(+), 273 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 03526bd49f..7511f7d31f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2268,7 +2268,7 @@ Http::post('/v1/account/tokens/magic-url') $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2580,7 +2580,7 @@ Http::post('/v1/account/tokens/email') $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.otpSession-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -3728,7 +3728,7 @@ Http::post('/v1/account/recovery') $preview = $locale->getText("emails.recovery.preview"); $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.recovery-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -4038,7 +4038,7 @@ Http::post('/v1/account/verifications/email') $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.verification-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 46806f79ed..23d3af075a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -852,13 +852,13 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -868,9 +868,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; - $fallbackLocale = System::getEnv('_APP_LOCALE', 'en'); - $localeObj = new Locale($locale === 'worldwide' ? $fallbackLocale : $locale); - $localeObj->setFallback($fallbackLocale); + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); if (is_null($template)) { /** @@ -954,10 +953,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -965,7 +961,10 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1014,13 +1013,13 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 7b33baced5..e59bbe3536 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -73,7 +73,7 @@ class Mails extends Listener $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? - $project->getAttribute('templates', [])['email.sessionAlert-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 319e080f25..14dc4e3237 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -220,7 +220,7 @@ class Create extends Action $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.mfaChallenge-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 161e817aed..aa4ee2c66c 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -326,7 +326,7 @@ class Create extends Action $subject = $locale->getText('emails.invitation.subject'); $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.invitation-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.invitation-' . $locale->fallback] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php index b0e127e07f..3ce9cacdb3 100644 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ b/src/Appwrite/Utopia/Response/Model/Template.php @@ -19,7 +19,7 @@ abstract class Template extends Model 'type' => self::TYPE_STRING, 'description' => 'Template locale', 'default' => '', - 'example' => 'worldwide', + 'example' => 'en_us', ]) ->addRule('message', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e4dfb8d85b..597030413e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,253 +1163,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } - #[Group('smtpAndTemplates')] - public function testWorldwideTemplates(): void - { - $data = $this->setupProjectData(); - $id = $data['projectId']; - - /** Get default template without locale (should default to worldwide) */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Get default template with explicit worldwide locale */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Set a worldwide email template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'Worldwide verify subject', - 'message' => 'Worldwide verify message {{url}}', - 'senderName' => 'Worldwide Sender', - 'senderEmail' => 'worldwide@appwrite.io', - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide verify subject', $response['body']['subject']); - $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); - $this->assertEquals('Worldwide Sender', $response['body']['senderName']); - $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); - $this->assertEquals('verification', $response['body']['type']); - - /** Get the worldwide template back */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide verify subject', $response['body']['subject']); - $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); - $this->assertEquals('Worldwide Sender', $response['body']['senderName']); - $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Locale-specific template should not return the worldwide custom template */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('en-us', $response['body']['locale']); - // en-us should NOT return the worldwide custom subject - $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); - - /** Delete the worldwide template */ - $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - - /** After deletion, worldwide GET should return default template */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - // Should be back to default (no custom subject) - $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); - } - - #[Group('smtpAndTemplates')] - public function testWorldwideFallbackOnMagicURL(): void - { - $smtpHost = 'maildev'; - $smtpPort = 1025; - $smtpUsername = 'user'; - $smtpPassword = 'password'; - - /** Create a dedicated project for this test */ - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Worldwide Fallback Test Team', - ]); - $this->assertEquals(201, $team['headers']['status-code']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'projectId' => ID::unique(), - 'name' => 'Worldwide Fallback Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default'), - ]); - $this->assertEquals(201, $project['headers']['status-code']); - $projectId = $project['body']['$id']; - - /** Enable SMTP on the project pointing to maildev */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'enabled' => true, - 'senderEmail' => 'mailer@appwrite.io', - 'senderName' => 'Mailer', - 'host' => $smtpHost, - 'port' => $smtpPort, - 'username' => $smtpUsername, - 'password' => $smtpPassword, - ]); - $this->assertEquals(200, $response['headers']['status-code']); - - /** Set worldwide magicSession template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'Worldwide Magic Login', - 'message' => 'Worldwide magic link: {{url}}', - 'senderName' => 'Worldwide Mailer', - 'senderEmail' => 'worldwide@appwrite.io', - ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); - - /** Set German (de) magicSession template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'German Magic Login', - 'message' => 'German magic link: {{url}}', - 'senderName' => 'German Mailer', - 'senderEmail' => 'german@appwrite.io', - ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('German Magic Login', $response['body']['subject']); - - /** Verify worldwide template is stored correctly */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); - - /** Verify German template is stored correctly */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('German Magic Login', $response['body']['subject']); - - /** Verify SMTP is enabled on the project */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertTrue($response['body']['smtpEnabled']); - - /** Trigger magic URL with English locale — should use worldwide fallback */ - $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'en', - ], [ - 'userId' => ID::unique(), - 'email' => $emailEn, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Trigger magic URL with German locale — should use German template */ - $emailDe = 'magic-de-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'de', - ], [ - 'userId' => ID::unique(), - 'email' => $emailDe, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Trigger magic URL with Polish locale — should use worldwide fallback */ - $emailPl = 'magic-pl-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'pl', - ], [ - 'userId' => ID::unique(), - 'email' => $emailPl, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Verify English email uses worldwide fallback template */ - $lastEmailEn = $this->getLastEmailByAddress($emailEn); - $this->assertEquals('Worldwide Magic Login', $lastEmailEn['subject']); - $this->assertEquals('worldwide@appwrite.io', $lastEmailEn['from'][0]['address']); - $this->assertEquals('Worldwide Mailer', $lastEmailEn['from'][0]['name']); - $this->assertStringContainsString('Worldwide magic link:', $lastEmailEn['html']); - - /** Verify German email uses the German-specific template */ - $lastEmailDe = $this->getLastEmailByAddress($emailDe); - $this->assertEquals('German Magic Login', $lastEmailDe['subject']); - $this->assertEquals('german@appwrite.io', $lastEmailDe['from'][0]['address']); - $this->assertEquals('German Mailer', $lastEmailDe['from'][0]['name']); - $this->assertStringContainsString('German magic link:', $lastEmailDe['html']); - - /** Verify Polish email uses worldwide fallback template */ - $lastEmailPl = $this->getLastEmailByAddress($emailPl); - $this->assertEquals('Worldwide Magic Login', $lastEmailPl['subject']); - $this->assertEquals('worldwide@appwrite.io', $lastEmailPl['from'][0]['address']); - $this->assertEquals('Worldwide Mailer', $lastEmailPl['from'][0]['name']); - $this->assertStringContainsString('Worldwide magic link:', $lastEmailPl['html']); - } - public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 1b826df8f97aae963f9857898f043f4df0be836e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:24:59 +0200 Subject: [PATCH 83/96] Non-URL locale to allow optional --- app/controllers/api/projects.php | 9 ++++++--- src/Appwrite/Bus/Listeners/Mails.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 23d3af075a..439692e1dd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -833,7 +833,8 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/email/:type/:locale') +Http::get('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -934,7 +935,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); -Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') +Http::patch('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Update custom email templates') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -993,7 +995,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index e59bbe3536..3d31101d2b 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -72,7 +72,7 @@ class Mails extends Listener } $customTemplate = - $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? + $project->getAttribute('templates', [])["email.sessionAlert-" . $locale->default] ?? $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; From bf9bb22ac5dd140d866e2a42d8690e574fc23320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:30:24 +0200 Subject: [PATCH 84/96] New tests --- .../Projects/ProjectsConsoleClientTest.php | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 597030413e..f937317b8f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,6 +1163,207 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } + #[Group('smtpAndTemplates')] + public function testSessionAlertLocaleFallback(): void + { + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; + + /** Create team */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test Team', + ]); + $this->assertEquals(201, $team['headers']['status-code']); + + /** Create project */ + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** Configure custom SMTP pointing to maildev */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** + * Set custom sessionAlert template with no explicit locale. + * When locale is omitted, the server stores it under the request's + * default locale (en), which is the same slot used as the system-wide + * fallback when a session's locale has no dedicated template. + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + // Intentionally no locale + 'subject' => 'Fallback sign-in alert', + 'message' => 'Fallback sign-in alert body', + 'senderName' => 'Fallback Mailer', + 'senderEmail' => 'fallback@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Fallback sign-in alert', $response['body']['subject']); + $this->assertEquals('Fallback sign-in alert body', $response['body']['message']); + $this->assertEquals('Fallback Mailer', $response['body']['senderName']); + $this->assertEquals('fallback@appwrite.io', $response['body']['senderEmail']); + + /** Set custom sessionAlert template for Slovak locale */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + 'locale' => 'sk', + 'subject' => 'Slovak sign-in alert', + 'message' => 'Slovak sign-in alert body', + 'senderName' => 'Slovak Mailer', + 'senderEmail' => 'sk@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Slovak sign-in alert', $response['body']['subject']); + $this->assertEquals('Slovak sign-in alert body', $response['body']['message']); + $this->assertEquals('Slovak Mailer', $response['body']['senderName']); + $this->assertEquals('sk@appwrite.io', $response['body']['senderEmail']); + + /** Enable session alerts */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'alerts' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** Verify alerts are enabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authSessionAlerts']); + + /** Create user (email + password) in the project */ + $userEmail = 'session-alert-' . uniqid() . '@appwrite.io'; + $password = 'password'; + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $userEmail, + 'password' => $password, + 'name' => 'Session Alert User', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Prime first session — the listener suppresses the alert on the very + * first session of a user, so this session is setup only. + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Create a new session with no locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with German locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'de', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with Slovak locale — expect Slovak template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'sk', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']); + $this->assertEquals('sk@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']); + + /** Cleanup — delete the project */ + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + + /** Cleanup — delete the team */ + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From c97dd783353bf174edc9df7c5e7639a63bde1e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:40:05 +0200 Subject: [PATCH 85/96] Fix tests --- .../Projects/ProjectsConsoleClientTest.php | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f937317b8f..59ff5e353c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1180,6 +1180,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Session Alert Locale Fallback Test Team', ]); $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; /** Create project */ $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ @@ -1188,7 +1189,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'projectId' => ID::unique(), 'name' => 'Session Alert Locale Fallback Test', - 'teamId' => $team['body']['$id'], + 'teamId' => $teamId, 'region' => System::getEnv('_APP_REGION', 'default'), ]); $this->assertEquals(201, $project['headers']['status-code']); @@ -1307,9 +1308,17 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** + * Emails are delivered asynchronously via the mail queue, so maildev may + * still be catching up. The probe callback forces getLastEmailByAddress + * to keep polling until an email matching the expected `from` address + * appears — i.e. we await the new email rather than returning an older + * one already in the inbox from a previous session. + */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); - $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); @@ -1325,9 +1334,11 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** Probe on `from` address ensures we await a fallback-shaped email */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); - $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); @@ -1343,9 +1354,11 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** Probe on `from` address ensures we await the Slovak email specifically */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('sk@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']); - $this->assertEquals('sk@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']); From 47f3ab930b7ab744eb69779e5ab2485e7f6fe0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 13:14:34 +0200 Subject: [PATCH 86/96] Remove /status from project paths; Upgrade to platform 0.13 --- composer.json | 2 +- composer.lock | 14 +++++++------- .../Functions/Http/Deployments/Download/Get.php | 2 +- .../Http/Project/Protocols/{Status => }/Update.php | 13 +++++++------ .../Http/Project/Services/{Status => }/Update.php | 13 +++++++------ .../Platform/Modules/Project/Services/Http.php | 8 ++++---- src/Appwrite/Utopia/Request/Filters/V19.php | 7 +++++++ 7 files changed, 34 insertions(+), 25 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/{Status => }/Update.php (89%) rename src/Appwrite/Platform/Modules/Project/Http/Project/Services/{Status => }/Update.php (89%) diff --git a/composer.json b/composer.json index 3aa6d157cf..6312243e32 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.9.*", - "utopia-php/platform": "0.12.*", + "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", "utopia-php/preloader": "0.2.*", diff --git a/composer.lock b/composer.lock index bc3d9d30bf..b1d559f87d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f6a87c1012b316e614258f8f57a28e48", + "content-hash": "c5ae97637fd0ec0a950044d1c33677ea", "packages": [ { "name": "adhocore/jwt", @@ -4642,16 +4642,16 @@ }, { "name": "utopia-php/platform", - "version": "0.12.1", + "version": "0.13.0", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/d23af5349a7ea9ee11f9920a13626226f985522e", + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e", "shasum": "" }, "require": { @@ -4687,9 +4687,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.12.1" + "source": "https://github.com/utopia-php/platform/tree/0.13.0" }, - "time": "2026-04-08T04:11:31+00:00" + "time": "2026-04-17T09:57:18+00:00" }, { "name": "utopia-php/pools", diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php index 50c901e4c8..d3e7155dc6 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php @@ -31,7 +31,7 @@ class Get extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/download') - ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download', ['type' => 'output']) + ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download') ->groups(['api', 'functions']) ->desc('Get deployment download') ->label('scope', 'functions.read') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php similarity index 89% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php index 71c20faca7..ad5691c1e0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->setHttpPath('/v1/project/protocols/:protocolId') + ->httpAlias('/v1/project/protocols/:protocolId/status') ->httpAlias('/v1/projects/:projectId/api') - ->desc('Update project protocol status') + ->desc('Update project protocol') ->groups(['api', 'project']) ->label('scope', 'project.write') ->label('event', 'protocols.[protocolId].update') @@ -40,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateProtocolStatus', + name: 'updateProtocol', description: <<setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/services/:serviceId/status') + ->setHttpPath('/v1/project/services/:serviceId') + ->httpAlias('/v1/project/services/:serviceId/status') ->httpAlias('/v1/projects/:projectId/service') - ->desc('Update project service status') + ->desc('Update project service') ->groups(['api', 'project']) ->label('scope', 'project.write') ->label('event', 'services.[serviceId].update') @@ -40,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateServiceStatus', + name: 'updateService', description: <<addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); - $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); - $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); + $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); + $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/src/Appwrite/Utopia/Request/Filters/V19.php b/src/Appwrite/Utopia/Request/Filters/V19.php index e7789ac0f7..4f2be12367 100644 --- a/src/Appwrite/Utopia/Request/Filters/V19.php +++ b/src/Appwrite/Utopia/Request/Filters/V19.php @@ -35,6 +35,13 @@ class V19 extends Filter case 'functions.updateVariable': $content['secret'] = false; break; + case 'functions.getDeploymentDownload': + // Pre-1.7.0 clients call the legacy alias + // `/v1/functions/:functionId/deployments/:deploymentId/build/download`, + // which always downloaded the build output. The merged 1.7.0 endpoint + // requires an explicit `type` param, so force it to `output` here. + $content['type'] = 'output'; + break; } return $content; } From c484c487a9a0a19bb0ed3c71cc1969c3d1aafb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 13:19:20 +0200 Subject: [PATCH 87/96] Update tests --- tests/e2e/Services/Project/ProtocolsBase.php | 29 +++++++++++++++++++- tests/e2e/Services/Project/ServicesBase.php | 29 +++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php index 0187fc8463..f828994ea3 100644 --- a/tests/e2e/Services/Project/ProtocolsBase.php +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -241,6 +241,33 @@ trait ProtocolsBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateProtocolLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusForRest']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['protocolStatusForRest']); + } + // Helpers protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed @@ -254,7 +281,7 @@ trait ProtocolsBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId, $headers, [ 'enabled' => $enabled, ]); } diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index 1bc7ce5042..b5f94f8181 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -239,6 +239,33 @@ trait ServicesBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateServiceLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusForTeams']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['serviceStatusForTeams']); + } + // Helpers protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed @@ -252,7 +279,7 @@ trait ServicesBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId, $headers, [ 'enabled' => $enabled, ]); } From df0f7ba581ee5f2b7960914be5fdd7663a0a076b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 18:02:04 +0530 Subject: [PATCH 88/96] added bucket boundary --- app/realtime.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 192672a2f3..5631a7f860 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -398,7 +398,11 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); - $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram('realtime.server.delivery_delay', 'ms')); + $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.delivery_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + )); $attempts = 0; $start = time(); From 27b0e48296d75c88ab4dbed69f69a3aab3da9017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 14:53:59 +0200 Subject: [PATCH 89/96] Remove Status suffix from project event names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project.updateServiceStatus → project.updateService - project.updateProtocolStatus → project.updateProtocol --- src/Appwrite/Utopia/Request/Filters/V22.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 4f1e746775..7e4c5b8e41 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -73,10 +73,10 @@ class V22 extends Filter public function parse(array $content, string $model): array { switch ($model) { - case 'project.updateServiceStatus': + case 'project.updateService': $content = $this->parseUpdateServiceStatus($content); break; - case 'project.updateProtocolStatus': + case 'project.updateProtocol': $content = $this->parseUpdateProtocolStatus($content); break; case 'project.createKey': From 9765c7f0e313d00fafb5c5fabf751b912c4e2e3f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:30:22 +0100 Subject: [PATCH 90/96] feat: use buildTimeout from message payload in build worker Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 0071b03d2d..323abfd564 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -592,9 +592,9 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); + $timeout = (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)); - $jwtExpiry = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); + $jwtExpiry = $timeout; $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $apiKey = $jwtObj->encode([ From 4043153df313870ba5fe93c185bb272c36601fc4 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:41 +0100 Subject: [PATCH 91/96] fix: pass buildTimeout as parameter to buildDeployment to fix PHPStan error Co-Authored-By: Claude Sonnet 4.6 --- .../Platform/Modules/Functions/Workers/Builds.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 323abfd564..6b28de6601 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -144,7 +144,8 @@ class Builds extends Action $log, $executor, $plan, - $platform + $platform, + (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -179,7 +180,8 @@ class Builds extends Action Log $log, Executor $executor, array $plan, - array $platform + array $platform, + int $buildTimeout = 900 ): void { Console::info('Deployment action started'); @@ -592,7 +594,7 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)); + $timeout = $buildTimeout; $jwtExpiry = $timeout; $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); From 8f39783d7a30033c22b7b92c837928a4a1160c8c Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:58 +0100 Subject: [PATCH 92/96] refactor: remove jwtExpiry alias, use timeout directly Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 6b28de6601..d6184107bd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -596,8 +596,7 @@ class Builds extends Action $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); $timeout = $buildTimeout; - $jwtExpiry = $timeout; - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), From 7df181420322fa617d022ed0d2d67cfeb393130e Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:22:53 +0100 Subject: [PATCH 93/96] refactor: rename buildTimeout to timeout in payload and buildDeployment param Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index d6184107bd..87e936a965 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -145,7 +145,7 @@ class Builds extends Action $executor, $plan, $platform, - (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) + (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -181,7 +181,7 @@ class Builds extends Action Executor $executor, array $plan, array $platform, - int $buildTimeout = 900 + int $timeout ): void { Console::info('Deployment action started'); @@ -594,8 +594,6 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = $buildTimeout; - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0); $apiKey = $jwtObj->encode([ From 956285d522b820dc869140c26383ad8667d3ca45 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:35:26 +0100 Subject: [PATCH 94/96] fix: do not cache error responses for storage preview, bump utopia-php/image to 0.8.5 Cache write hook now checks HTTP status code before writing to prevent failed AVIF (or any other) conversions from poisoning the cache. Bumps utopia-php/image to 0.8.5 which fixes AVIF/HEIC output by using native Imagick instead of the deprecated magick convert shell command. Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/shared/api.php | 3 +- composer.lock | 22 ++++---- tests/e2e/Services/Storage/StorageBase.php | 65 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5567281e67..bba00bede1 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -971,7 +971,8 @@ Http::shutdown() if ($useCache) { $resource = $resourceType = null; $data = $response->getPayload(); - if (! empty($data['payload'])) { + $statusCode = $response->getStatusCode(); + if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) { $pattern = $route->getLabel('cache.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); diff --git a/composer.lock b/composer.lock index b1d559f87d..56b838a0fe 100644 --- a/composer.lock +++ b/composer.lock @@ -4325,16 +4325,16 @@ }, { "name": "utopia-php/image", - "version": "0.8.4", + "version": "0.8.5", "source": { "type": "git", "url": "https://github.com/utopia-php/image.git", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65" + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/image/zipball/ce788ff0121a79286fdbe3ef3eba566de646df65", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65", + "url": "https://api.github.com/repos/utopia-php/image/zipball/9af2fcff028a42550465e2ccad88e3b31c3584f3", + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3", "shasum": "" }, "require": { @@ -4343,10 +4343,12 @@ "php": ">=8.1" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "^1.10.0", - "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.13.1" + "laravel/pint": "1.24.*", + "phpstan/phpstan": "2.1.*", + "phpunit/phpunit": "10.5.*" + }, + "suggest": { + "ext-imagick": "Imagick extension is required for Imagick adapter" }, "type": "library", "autoload": { @@ -4368,9 +4370,9 @@ ], "support": { "issues": "https://github.com/utopia-php/image/issues", - "source": "https://github.com/utopia-php/image/tree/0.8.4" + "source": "https://github.com/utopia-php/image/tree/0.8.5" }, - "time": "2025-06-03T08:32:20+00:00" + "time": "2026-04-17T15:02:49+00:00" }, { "name": "utopia-php/locale", diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index d1cb548016..60a4aefc85 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1050,6 +1050,28 @@ trait StorageBase $this->assertEquals(404, $file['headers']['status-code']); } + public function testFilePreviewAvifPublic(): void + { + $data = $this->setupBucketFile(); + $bucketId = $data['bucketId']; + $fileId = $data['fileId']; + $projectId = $this->getProject()['$id']; + + // Matches the customer's URL pattern: no headers, project + output in query string only + $preview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [ + 'content-type' => 'application/json', + ], [ + 'project' => $projectId, + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $preview['headers']['status-code']); + $this->assertEquals('image/avif', $preview['headers']['content-type']); + $this->assertNotEmpty($preview['body']); + } + public function testFilePreview(): void { $data = $this->setupBucketFile(); @@ -1069,6 +1091,49 @@ trait StorageBase $this->assertEquals(200, $preview['headers']['status-code']); $this->assertEquals('image/webp', $preview['headers']['content-type']); $this->assertNotEmpty($preview['body']); + + // Preview PNG as avif + $avifPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifPreview['headers']['status-code']); + $this->assertEquals('image/avif', $avifPreview['headers']['content-type']); + $this->assertNotEmpty($avifPreview['body']); + + // Preview JPEG as avif + $jpegFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-a/kitten-1.jpg'), 'image/jpeg', 'kitten-1.jpg'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $jpegFile['headers']['status-code']); + + $avifFromJpeg = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $jpegFile['body']['$id'] . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifFromJpeg['headers']['status-code']); + $this->assertEquals('image/avif', $avifFromJpeg['headers']['content-type']); + $this->assertNotEmpty($avifFromJpeg['body']); } public function testDeletePartiallyUploadedFile(): void From ad3bdee6c1b7bfac0eee1bfd982f9fff72098da7 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:34:13 +0100 Subject: [PATCH 95/96] fix: include project ID in storage preview cache key Cache key never included the project ID, so two projects with the same bucketId, fileId, and transform params would share a cache key. On a cache hit, Appwrite re-validates the bucket from the cached resourceType (another project's bucket), which doesn't exist in the requesting project's DB, throwing storage_bucket_not_found. Fix: add 'project' to cache.params on the preview route (covers query param case) and fall back to the X-Appwrite-Project header in cacheIdentifier() for authenticated requests. Co-Authored-By: Claude Sonnet 4.6 --- .../Modules/Storage/Http/Buckets/Files/Preview/Get.php | 2 +- src/Appwrite/Utopia/Request.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f0ee045214..f6b6eb25da 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -54,7 +54,7 @@ class Get extends Action ->label('cache', true) ->label('cache.resourceType', 'bucket/{request.bucketId}') ->label('cache.resource', 'file/{request.fileId}') - ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output']) + ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output', 'project']) ->label('sdk', new Method( namespace: 'storage', group: 'files', diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 3f1ea794ab..bd0a870f7a 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -239,6 +239,9 @@ class Request extends UtopiaRequest $params = array_intersect_key($params, array_flip($allowedParams)); } ksort($params); + if (!isset($params['project'])) { + $params['project'] = $this->getHeader('x-appwrite-project', ''); + } return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); } From 08b43dce504a3a8b8c6e85ccf6667f6f6f2275b9 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:45:00 +0100 Subject: [PATCH 96/96] fix: ksort after project injection to keep cache key order stable Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Utopia/Request.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index bd0a870f7a..32f0fa89a9 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -238,10 +238,10 @@ class Request extends UtopiaRequest if ($allowedParams !== null) { $params = array_intersect_key($params, array_flip($allowedParams)); } - ksort($params); if (!isset($params['project'])) { $params['project'] = $this->getHeader('x-appwrite-project', ''); } + ksort($params); return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); }