From 14869cc6d6e196295616fa707a7e61f5e50ba1b5 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 09:59:53 +0300 Subject: [PATCH 01/92] Safe delete shared tables v1 --- src/Appwrite/Platform/Workers/Deletes.php | 24 ++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 716969e67a..35892218d9 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -765,11 +765,29 @@ class Deletes extends Action $databasesToClean )); } elseif ($sharedTablesV1) { + var_dump('================= Delete Shared table v1 =================='); + var_dump($projectCollectionIds); + $metadataDocuments = $dbForProject->find(Database::METADATA, [ + Query::equal('$id', $projectCollectionIds), + Query::limit(PHP_INT_MAX) + ]); + + $queries = [ + Query::orderAsc() + ]; + + foreach ($metadataDocuments as $metadataDoc) { + if (empty($metadataDoc->getTenant())) { + var_dump('=== Found null metadata ==='); + var_dump($metadataDoc->getId()); + var_dump($metadataDoc->getSequence()); + $queries[] = Query::notEqual('$sequence', $metadataDoc->getSequence()); + } + } + $this->deleteByGroup( Database::METADATA, - [ - Query::orderAsc() - ], + $queries, $dbForProject ); } elseif ($sharedTablesV2) { From 2a9e423cb1924f72a00023e4a5b10c859db7539c Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 12:54:33 +0300 Subject: [PATCH 02/92] Temporary disabling deletes from internal collections --- src/Appwrite/Platform/Workers/Deletes.php | 26 +++++++---------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 35892218d9..6cb85ca492 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -765,25 +765,15 @@ class Deletes extends Action $databasesToClean )); } elseif ($sharedTablesV1) { - var_dump('================= Delete Shared table v1 =================='); - var_dump($projectCollectionIds); - $metadataDocuments = $dbForProject->find(Database::METADATA, [ - Query::equal('$id', $projectCollectionIds), - Query::limit(PHP_INT_MAX) - ]); + /** + * Temporary disabling deletes from internal collections + */ + $queries = \array_map( + fn ($id) => Query::notEqual('$id', $id), + $projectCollectionIds + ); - $queries = [ - Query::orderAsc() - ]; - - foreach ($metadataDocuments as $metadataDoc) { - if (empty($metadataDoc->getTenant())) { - var_dump('=== Found null metadata ==='); - var_dump($metadataDoc->getId()); - var_dump($metadataDoc->getSequence()); - $queries[] = Query::notEqual('$sequence', $metadataDoc->getSequence()); - } - } + $queries[] = Query::orderAsc(); $this->deleteByGroup( Database::METADATA, From 2c4770d29b695ba4152a6511c20db2b6264e418d Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 19:18:32 +0300 Subject: [PATCH 03/92] Database 5.3.19 --- composer.lock | 84 +++++++++++++++++++++------------------------------ 1 file changed, 34 insertions(+), 50 deletions(-) diff --git a/composer.lock b/composer.lock index 1441bf06d1..43355afb6e 100644 --- a/composer.lock +++ b/composer.lock @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.17", + "version": "5.3.19", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993" + "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/cff2b6ed63d3291b74110d086e16ff089fe05993", - "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993", + "url": "https://api.github.com/repos/utopia-php/database/zipball/72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", + "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", "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.17" + "source": "https://github.com/utopia-php/database/tree/5.3.19" }, - "time": "2026-03-20T01:18:52+00:00" + "time": "2026-03-31T15:52:08+00:00" }, { "name": "utopia-php/detector", @@ -5439,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.14.0", + "version": "1.16.4", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e" + "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a56cb7942afbcbbc11a323e274a8798539ebebb9", + "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9", "shasum": "" }, "require": { @@ -5484,22 +5484,22 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.14.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.16.4" }, - "time": "2026-03-26T12:50:11+00:00" + "time": "2026-03-31T13:07:14+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -5523,7 +5523,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.44", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", @@ -5567,7 +5567,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -5579,7 +5579,7 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "czproject/git-php", @@ -6195,11 +6195,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.45", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f8cdfd9421b7edb7686a2d150a234870464eac70", + "reference": "f8cdfd9421b7edb7686a2d150a234870464eac70", "shasum": "" }, "require": { @@ -6244,7 +6244,7 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-03-30T13:22:02+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6594,16 +6594,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.15", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", + "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", "shasum": "" }, "require": { @@ -6625,7 +6625,7 @@ "sebastian/cli-parser": "^4.2.0", "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0.4", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -6672,31 +6672,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.15" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-03-31T06:41:33+00:00" }, { "name": "sebastian/cli-parser", @@ -8435,7 +8419,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8456,5 +8440,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From e42a26fdad247d7ca0f8313b280872fcefe3d5b0 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 19:20:13 +0300 Subject: [PATCH 04/92] comment --- 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 6cb85ca492..750fafbaf8 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -766,7 +766,7 @@ class Deletes extends Action )); } elseif ($sharedTablesV1) { /** - * Temporary disabling deletes from internal collections + * Temporary disabling deletes for internal collections */ $queries = \array_map( fn ($id) => Query::notEqual('$id', $id), From 9237f387fc582e5d105f891064969150ad8ed08f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 1 Apr 2026 17:59:28 +0530 Subject: [PATCH 05/92] added reset in db worker for queue for realtime --- .../Modules/Databases/Workers/Databases.php | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 66ed3e0eab..54700ca365 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -650,26 +650,32 @@ class Databases extends Action Document|null $attribute = null, Document|null $index = null, ): void { - $queueForRealtime - ->setProject($project) - ->setSubscribers(['console']) - ->setEvent($event) - ->setParam('databaseId', $database->getId()) - ->setParam('tableId', $collection->getId()) - ->setParam('collectionId', $collection->getId()); + try { + $queueForRealtime + ->setProject($project) + ->setSubscribers(['console']) + ->setEvent($event) + ->setParam('databaseId', $database->getId()) + ->setParam('tableId', $collection->getId()) + ->setParam('collectionId', $collection->getId()); - if (! empty($attribute)) { - $queueForRealtime - ->setParam('columnId', $attribute->getId()) - ->setParam('attributeId', $attribute->getId()) - ->setPayload($attribute->getArrayCopy()); - } - if (! empty($index)) { - $queueForRealtime - ->setParam('indexId', $index->getId()) - ->setPayload($index->getArrayCopy()); + if (! empty($attribute)) { + $queueForRealtime + ->setParam('columnId', $attribute->getId()) + ->setParam('attributeId', $attribute->getId()) + ->setPayload($attribute->getArrayCopy()); + } + if (! empty($index)) { + $queueForRealtime + ->setParam('indexId', $index->getId()) + ->setPayload($index->getArrayCopy()); + } + $queueForRealtime->trigger(); + } catch (\Throwable $th) { + throw $th; + } finally { + $queueForRealtime->reset(); } - $queueForRealtime->trigger(); } } From 2a184288f4116c8cb01366a824ad12e91f3ab7cc Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 1 Apr 2026 18:05:56 +0530 Subject: [PATCH 06/92] removed throw --- src/Appwrite/Platform/Modules/Databases/Workers/Databases.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 54700ca365..a50e8f8bdf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -671,8 +671,6 @@ class Databases extends Action ->setPayload($index->getArrayCopy()); } $queueForRealtime->trigger(); - } catch (\Throwable $th) { - throw $th; } finally { $queueForRealtime->reset(); } From cf99269bc55a6ce2efd57a45778e453547eb3c2b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 1 Apr 2026 18:08:45 +0530 Subject: [PATCH 07/92] Add reset method to Realtime class for clearing event state This new method resets the event state for long-lived worker processes by clearing subscribers, context, and user-related fields, ensuring no stale state affects subsequent triggers. --- src/Appwrite/Event/Realtime.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Appwrite/Event/Realtime.php b/src/Appwrite/Event/Realtime.php index 747fd786f9..f040d91468 100644 --- a/src/Appwrite/Event/Realtime.php +++ b/src/Appwrite/Event/Realtime.php @@ -61,6 +61,26 @@ class Realtime extends Event return $this->subscribers; } + /** + * Reset the event state for long-lived worker processes. + * + * `Event::reset()` clears params/sensitive/event/payload only. Realtime routing also + * depends on `context`, `subscribers`, and `project`/`user` fields, so we clear them too + * to prevent stale state from affecting subsequent triggers. + */ + public function reset(): self + { + parent::reset(); + + $this->subscribers = []; + $this->context = []; + $this->project = null; + $this->user = null; + $this->userId = null; + + return $this; + } + /** * Execute Event. * From 775d21e5ee2ea5f4383c9ba63e404effcc398fcf Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 5 Apr 2026 10:08:29 +0300 Subject: [PATCH 08/92] lock --- composer.lock | 92 +++++++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/composer.lock b/composer.lock index 43355afb6e..711ee8a852 100644 --- a/composer.lock +++ b/composer.lock @@ -161,16 +161,16 @@ }, { "name": "appwrite/php-runtimes", - "version": "0.19.4", + "version": "0.19.5", "source": { "type": "git", "url": "https://github.com/appwrite/runtimes.git", - "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b" + "reference": "aa2f7760cd0493c0880209b92df812c9386b3546" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/runtimes/zipball/eea9d1b3ca2540eab623b419c8afde09ef406c0b", - "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b", + "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546", + "reference": "aa2f7760cd0493c0880209b92df812c9386b3546", "shasum": "" }, "require": { @@ -210,9 +210,9 @@ ], "support": { "issues": "https://github.com/appwrite/runtimes/issues", - "source": "https://github.com/appwrite/runtimes/tree/0.19.4" + "source": "https://github.com/appwrite/runtimes/tree/0.19.5" }, - "time": "2026-02-17T10:04:39+00:00" + "time": "2026-04-01T01:39:23+00:00" }, { "name": "brick/math", @@ -2708,16 +2708,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -2785,7 +2785,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -2805,7 +2805,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -5439,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.16.4", + "version": "1.17.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9" + "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a56cb7942afbcbbc11a323e274a8798539ebebb9", - "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/bd6139cbba4d194f1729fe8eb378014d8b6589c5", + "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5", "shasum": "" }, "require": { @@ -5484,9 +5484,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.16.4" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.1" }, - "time": "2026-03-31T13:07:14+00:00" + "time": "2026-04-04T10:24:20+00:00" }, { "name": "brianium/paratest", @@ -6195,11 +6195,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.45", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f8cdfd9421b7edb7686a2d150a234870464eac70", - "reference": "f8cdfd9421b7edb7686a2d150a234870464eac70", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -6244,7 +6244,7 @@ "type": "github" } ], - "time": "2026-03-30T13:22:02+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6594,16 +6594,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.15", + "version": "12.5.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a" + "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", - "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b2429f58ae75cae980b5bb9873abe4de6aac8b58", + "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58", "shasum": "" }, "require": { @@ -6672,7 +6672,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.15" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.16" }, "funding": [ { @@ -6680,7 +6680,7 @@ "type": "other" } ], - "time": "2026-03-31T06:41:33+00:00" + "time": "2026-04-03T05:26:42+00:00" }, { "name": "sebastian/cli-parser", @@ -7665,16 +7665,16 @@ }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -7731,7 +7731,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -7751,7 +7751,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8085,16 +8085,16 @@ }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { @@ -8126,7 +8126,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -8146,20 +8146,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -8216,7 +8216,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -8236,7 +8236,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "textalk/websocket", From 31728c9b72d8953fe80d671a81ddbd762b30ed07 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 6 Apr 2026 09:29:58 +0300 Subject: [PATCH 09/92] Update lock --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 4bf1fc072b..c370e02cd1 100644 --- a/composer.lock +++ b/composer.lock @@ -5439,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.1", + "version": "1.17.2", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5" + "reference": "a96a90d95849a44bf940b392925abb2af6813110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/bd6139cbba4d194f1729fe8eb378014d8b6589c5", - "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a96a90d95849a44bf940b392925abb2af6813110", + "reference": "a96a90d95849a44bf940b392925abb2af6813110", "shasum": "" }, "require": { @@ -5484,9 +5484,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.1" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.2" }, - "time": "2026-04-04T10:24:20+00:00" + "time": "2026-04-05T15:46:09+00:00" }, { "name": "brianium/paratest", From e9987399980d2fbd664732fbaa0484dcc6160a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:18:26 +0200 Subject: [PATCH 10/92] Add agent skill to increase patch version --- .../skills/patch-release-checklist/SKILL.md | 32 +++++++++++++++++++ AGENTS.md | 4 +++ 2 files changed, 36 insertions(+) create mode 100644 .claude/skills/patch-release-checklist/SKILL.md diff --git a/.claude/skills/patch-release-checklist/SKILL.md b/.claude/skills/patch-release-checklist/SKILL.md new file mode 100644 index 0000000000..caf923a8d3 --- /dev/null +++ b/.claude/skills/patch-release-checklist/SKILL.md @@ -0,0 +1,32 @@ +# Patch Release Checklist for Appwrite + +When bumping a patch version (e.g., `1.9.0` -> `1.9.1`), follow this checklist. + +## Checklist + +### Bump console image + +Update the console Docker image tag in both files: +- [ ] `docker-compose.yml` -- update `image: appwrite/console:X.Y.Z` +- [ ] `app/views/install/compose.phtml` -- update `image: /console:X.Y.Z` + +### Bump Appwrite version + +These 4 files are always changed together in one commit: + +- [ ] **`app/init/constants.php`** -- update `APP_VERSION_STABLE` to the new version (e.g., `'1.9.1'`). In same file, increment `APP_CACHE_BUSTER` by 1. +- [ ] **`README.md`** -- update the Docker image tag `appwrite/appwrite:X.Y.Z` in all 3 install code blocks (Unix, Windows CMD, PowerShell). +- [ ] **`README-CN.md`** -- same Docker image tag update in all 3 install code blocks. +- [ ] **`src/Appwrite/Migration/Migration.php`** -- add the new version to the `$versions` array, mapping it to a migration class. If new class exists, use that, otherwise use sle same class as previous version + +### Update CHANGES.md (separate commit after version bump) + +- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md`. +- [ ] Categorize changes under subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous` + +## Final review + +- [ ] Ask user to review changes before commiting +- [ ] Ask user to update `CHANGES.md` with PRs +- [ ] Ask user to generate specs, if needed +- [ ] Ask user to add request and response filters, if needed diff --git a/AGENTS.md b/AGENTS.md index 4d11ff0ee3..4c5db871d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,10 @@ Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, ` - Never hardcode credentials -- use environment variables. - Code changes may require container restart. No central log location -- check relevant containers. +## Patch release process + +For bumping patch versions (e.g., `1.9.0` -> `1.9.1`), follow the checklist in `.claude/skills/patch-release-checklist/SKILL.md`. It covers the 4 files that must be updated, console image bumps, CHANGES.md updates, and common pitfalls to avoid. + ## Cross-repo context Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console. From d6d118f4ab89a64733a208bcf10310ae20960e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:19:58 +0200 Subject: [PATCH 11/92] Bump version to 1.9.1 --- README-CN.md | 6 +++--- README.md | 6 +++--- app/init/constants.php | 4 ++-- src/Appwrite/Migration/Migration.php | 1 + 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README-CN.md b/README-CN.md index 212b5bb08d..2c7402f1ef 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 88d527f060..31076ffa31 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/init/constants.php b/app/init/constants.php index 3b907572ab..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,8 +46,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4321; -const APP_VERSION_STABLE = '1.9.0'; +const APP_CACHE_BUSTER = 4322; +const APP_VERSION_STABLE = '1.9.1'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index e481eebf6e..a01031de9b 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -93,6 +93,7 @@ abstract class Migration '1.8.0' => 'V23', '1.8.1' => 'V23', '1.9.0' => 'V24', + '1.9.1' => 'V24', ]; /** From 75324b24fc7056a5425961786977e912ec9265ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:21:24 +0200 Subject: [PATCH 12/92] Improve skill --- .claude/skills/patch-release-checklist/SKILL.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.claude/skills/patch-release-checklist/SKILL.md b/.claude/skills/patch-release-checklist/SKILL.md index caf923a8d3..000fcd065a 100644 --- a/.claude/skills/patch-release-checklist/SKILL.md +++ b/.claude/skills/patch-release-checklist/SKILL.md @@ -12,17 +12,14 @@ Update the console Docker image tag in both files: ### Bump Appwrite version -These 4 files are always changed together in one commit: - - [ ] **`app/init/constants.php`** -- update `APP_VERSION_STABLE` to the new version (e.g., `'1.9.1'`). In same file, increment `APP_CACHE_BUSTER` by 1. - [ ] **`README.md`** -- update the Docker image tag `appwrite/appwrite:X.Y.Z` in all 3 install code blocks (Unix, Windows CMD, PowerShell). - [ ] **`README-CN.md`** -- same Docker image tag update in all 3 install code blocks. - [ ] **`src/Appwrite/Migration/Migration.php`** -- add the new version to the `$versions` array, mapping it to a migration class. If new class exists, use that, otherwise use sle same class as previous version -### Update CHANGES.md (separate commit after version bump) +### Update CHANGES.md -- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md`. -- [ ] Categorize changes under subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous` +- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md` with subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous` ## Final review From 8818187740dc7c88d4d2fff27ad342e742332b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:21:58 +0200 Subject: [PATCH 13/92] Introduce req&res filters for 1.9.1 --- app/controllers/general.php | 8 ++++++++ src/Appwrite/Utopia/Request/Filters/V22.php | 16 ++++++++++++++++ src/Appwrite/Utopia/Response/Filters/V22.php | 16 ++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 src/Appwrite/Utopia/Request/Filters/V22.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V22.php diff --git a/app/controllers/general.php b/app/controllers/general.php index c6e2eacb33..2a2b725262 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -25,6 +25,7 @@ use Appwrite\Utopia\Request\Filters\V18 as RequestV18; use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; +use Appwrite\Utopia\Request\Filters\V22 as RequestV22; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -32,6 +33,7 @@ use Appwrite\Utopia\Response\Filters\V18 as ResponseV18; use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; +use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -892,6 +894,9 @@ Http::init() if (version_compare($requestFormat, '1.9.0', '<')) { $request->addFilter(new RequestV21()); } + if (version_compare($requestFormat, '1.9.1', '<')) { + $request->addFilter(new RequestV22()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -916,6 +921,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.1', '<')) { + $response->addFilter(new ResponseV22()); + } if (version_compare($responseFormat, '1.9.0', '<')) { $response->addFilter(new ResponseV21()); } diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php new file mode 100644 index 0000000000..4d634620a2 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -0,0 +1,16 @@ + $content, + }; + } +} From d3c73fbb49e0c86ad59f392ad40937d916a6f65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:34:50 +0200 Subject: [PATCH 14/92] Add endpoints to control protocols and services --- app/controllers/api/projects.php | 190 +----------------- .../Http/Project/Protocols/Status/Update.php | 80 ++++++++ .../Http/Project/Services/Status/Update.php | 80 ++++++++ .../Modules/Project/Services/Http.php | 4 + src/Appwrite/Utopia/Request/Filters/V22.php | 36 ++++ 5 files changed, 204 insertions(+), 186 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dac6ed456a..569cea66d0 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -71,202 +71,20 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/service') - ->desc('Update service status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatus', - description: '/docs/references/projects/update-service-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('service', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name.') - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $service, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $services = $project->getAttribute('services', []); - $services[$service] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/service/all') ->desc('Update all service status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatusAll', - description: '/docs/references/projects/update-service-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allServices = array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])); - - $services = []; - foreach ($allServices as $service) { - $services[$service] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/api') - ->desc('Update API status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatus', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('api', '', new WhiteList(array_keys(Config::getParam('apis')), true), 'API name.') - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $api, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $apis = $project->getAttribute('apis', []); - $apis[$api] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_API_DISABLED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/api/all') ->desc('Update all API status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatusAll', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allApis = array_keys(Config::getParam('apis')); - - $apis = []; - foreach ($allApis as $api) { - $apis[$api] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_API_DISABLED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/oauth2') 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 new file mode 100644 index 0000000000..e20a3095a8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->httpAlias('/v1/projects/:projectId/api') + ->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('audits.resource', 'project.protocols/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateProtocolStatus', + description: <<param('protocolId', '', new WhiteList(array_keys(array_filter(Config::getParam('apis'), fn ($element) => $element['optional'])), true), 'Protocol name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('apis'), fn ($element) => $element['optional'])))) + ->param('enabled', null, new Boolean(), 'Protocol status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $protocolId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $protocols = $project->getAttribute('apis', []); + $protocols[$protocolId] = $enabled; + + $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $protocols))); + + $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 new file mode 100644 index 0000000000..f97bdf6c62 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/services/:serviceId/status') + ->httpAlias('/v1/projects/:projectId/service') + ->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('audits.resource', 'project.services/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateServiceStatus', + description: <<param('serviceId', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])))) + ->param('enabled', null, new Boolean(), 'Service status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $serviceId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $services = $project->getAttribute('services', []); + $services[$serviceId] = $enabled; + + $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 9fd8366097..a2c94928e2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,6 +22,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -40,6 +42,8 @@ class Http extends Service // Project $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); + $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 4d634620a2..6b4edab394 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -7,9 +7,45 @@ use Appwrite\Utopia\Request\Filter; class V22 extends Filter { // Convert 1.9.0 params to 1.9.1 + protected function parseUpdateProtocolStatus(array $content): array + { + if (isset($content['api'])) { + $content['protocolId'] = $content['api']; + unset($content['api']); + } + + if (isset($content['status'])) { + $content['enabled'] = $content['status']; + unset($content['status']); + } + + return $content; + } + + protected function parseUpdateServiceStatus(array $content): array + { + if (isset($content['service'])) { + $content['serviceId'] = $content['service']; + unset($content['service']); + } + + if (isset($content['status'])) { + $content['enabled'] = $content['status']; + unset($content['status']); + } + + return $content; + } + public function parse(array $content, string $model): array { switch ($model) { + case 'project.updateServiceStatus': + $content = $this->parseUpdateServiceStatus($content); + break; + case 'project.updateProtocolStatus': + $content = $this->parseUpdateProtocolStatus($content); + break; } return $content; } From a4a0c4175d572836bd0e80eaea928a3fe1dd9070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:45:06 +0200 Subject: [PATCH 15/92] Implement new endpoints in /v1/project for services and protocols --- app/config/{apis.php => protocols.php} | 6 ++-- app/config/services.php | 8 ++--- app/init/configs.php | 2 +- app/realtime.php | 5 +-- .../Http/Project/Protocols/Status/Update.php | 6 ++-- .../Http/Project/Services/Status/Update.php | 4 +-- src/Appwrite/Utopia/Request/Filters/V22.php | 4 +++ .../Utopia/Response/Model/Project.php | 33 +++++++++++++++++++ 8 files changed, 51 insertions(+), 17 deletions(-) rename app/config/{apis.php => protocols.php} (69%) diff --git a/app/config/apis.php b/app/config/protocols.php similarity index 69% rename from app/config/apis.php rename to app/config/protocols.php index a625999682..bb12453712 100644 --- a/app/config/apis.php +++ b/app/config/protocols.php @@ -9,8 +9,8 @@ return [ 'key' => 'graphql', 'name' => 'GraphQL', ], - 'realtime' => [ - 'key' => 'realtime', - 'name' => 'Realtime', + 'websocket' => [ + 'key' => 'websocket', + 'name' => 'Websocket', ], ]; diff --git a/app/config/services.php b/app/config/services.php index a99501c530..548f659a81 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -137,7 +137,7 @@ return [ 'docs' => true, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -193,7 +193,7 @@ return [ 'docs' => false, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -235,7 +235,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/proxy', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/proxy.png', 'platforms' => ['client', 'server', 'console'], ], @@ -291,7 +291,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/migrations', 'tests' => true, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/migrations.png', 'platforms' => ['client', 'server', 'console'], ], diff --git a/app/init/configs.php b/app/init/configs.php index 35c8e3899d..360a7abc34 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -12,7 +12,7 @@ Config::load('runtimes-v2', __DIR__ . '/../config/runtimes-v2.php', $configAdapt Config::load('template-runtimes', __DIR__ . '/../config/template-runtimes.php', $configAdapter); Config::load('events', __DIR__ . '/../config/events.php', $configAdapter); Config::load('auth', __DIR__ . '/../config/auth.php', $configAdapter); -Config::load('apis', __DIR__ . '/../config/apis.php', $configAdapter); // List of APIs +Config::load('protocols', __DIR__ . '/../config/protocols.php', $configAdapter); Config::load('errors', __DIR__ . '/../config/errors.php', $configAdapter); Config::load('oAuthProviders', __DIR__ . '/../config/oAuthProviders.php', $configAdapter); Config::load('sdks', __DIR__ . '/../config/sdks.php', $configAdapter); diff --git a/app/realtime.php b/app/realtime.php index 97ea7e32d5..489e26e393 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -660,9 +660,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $user = $app->getResource('user'); /** @var User $user */ $logUser = $user; + $apis = $project->getAttribute('apis', []); + $websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true; if ( - array_key_exists('realtime', $project->getAttribute('apis', [])) - && !$project->getAttribute('apis', [])['realtime'] + !$websocketEnabled && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); 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 e20a3095a8..27aefca4f3 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 @@ -12,9 +12,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Text; use Utopia\Validator\WhiteList; class Update extends Action @@ -53,7 +51,7 @@ class Update extends Action ) ], )) - ->param('protocolId', '', new WhiteList(array_keys(array_filter(Config::getParam('apis'), fn ($element) => $element['optional'])), true), 'Protocol name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('apis'), fn ($element) => $element['optional'])))) + ->param('protocolId', '', new WhiteList(array_keys(array_filter(Config::getParam('protocols'), fn ($element) => $element['optional'])), true), 'Protocol name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('protocols'), fn ($element) => $element['optional'])))) ->param('enabled', null, new Boolean(), 'Protocol status.') ->inject('response') ->inject('dbForPlatform') @@ -73,7 +71,7 @@ class Update extends Action $protocols = $project->getAttribute('apis', []); $protocols[$protocolId] = $enabled; - $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $protocols))); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $protocols))); $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 f97bdf6c62..21b6eff43e 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 @@ -12,9 +12,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Text; use Utopia\Validator\WhiteList; class Update extends Action @@ -73,7 +71,7 @@ class Update extends Action $services = $project->getAttribute('services', []); $services[$serviceId] = $enabled; - $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services))); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services))); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 6b4edab394..837ded5905 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -19,6 +19,10 @@ class V22 extends Filter unset($content['status']); } + if (($content['protocolId'] ?? '') === 'realtime') { + $content['protocolId'] = 'websocket'; + } + return $content; } diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 1ef73aa769..4cb038fc37 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -344,6 +344,22 @@ class Project extends Model ]) ; } + + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $name = $api['name'] ?? ''; + $key = $api['key'] ?? ''; + + $this + ->addRule('protocolStatusFor' . ucfirst($key), [ + 'type' => self::TYPE_BOOLEAN, + 'description' => $name . ' protocol status', + 'example' => true, + 'default' => true, + ]) + ; + } } /** @@ -375,6 +391,7 @@ class Project extends Model { $this->expandSmtpFields($document); $this->expandServiceFields($document); + $this->expandApiFields($document); $this->expandAuthFields($document); $this->expandOAuthProviders($document); @@ -419,6 +436,22 @@ class Project extends Model } } + private function expandApiFields(Document $document): void + { + if (!$document->isSet('apis')) { + return; + } + + $values = $document->getAttribute('apis', []); + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $key = $api['key'] ?? ''; + $value = $values[$key] ?? true; + $document->setAttribute('protocolStatusFor' . ucfirst($key), $value); + } + } + private function expandAuthFields(Document $document): void { if (!$document->isSet('auths')) { From 0293da1e223fa84b381267aba8aa8523b0a46206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:54:00 +0200 Subject: [PATCH 16/92] Improve test for backwards compatibility --- app/controllers/api/projects.php | 4 +- app/realtime.php | 1 + tests/e2e/Services/Projects/ProjectsBase.php | 1 + .../Projects/ProjectsConsoleClientTest.php | 259 +++++++++++++++--- 4 files changed, 229 insertions(+), 36 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 569cea66d0..41e8777edb 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -76,7 +76,7 @@ Http::patch('/v1/projects/:projectId/service/all') ->groups(['api', 'projects']) ->label('scope', 'projects.write') ->action(function () { - throw new Exception(Exception::GENERAL_API_DISABLED, 'Bulk API no longer exists for services. Please change status individually.'); + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); }); Http::patch('/v1/projects/:projectId/api/all') @@ -84,7 +84,7 @@ Http::patch('/v1/projects/:projectId/api/all') ->groups(['api', 'projects']) ->label('scope', 'projects.write') ->action(function () { - throw new Exception(Exception::GENERAL_API_DISABLED, 'Bulk API no longer exists for services. Please change status individually.'); + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); }); Http::patch('/v1/projects/:projectId/oauth2') diff --git a/app/realtime.php b/app/realtime.php index 489e26e393..c93f7e4e6a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -661,6 +661,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $logUser = $user; $apis = $project->getAttribute('apis', []); + // Websocket is what to check, but realtime is checked too for backwards compatibility $websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true; if ( !$websocketEnabled diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index d42c5feda3..6122e95c75 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -388,6 +388,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e0f94b64cc..096d90d8d2 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2497,49 +2497,222 @@ class ProjectsConsoleClientTest extends Scope $id = $project['body']['$id']; + // Bulk disable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ])); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $matches = []; - $pattern = '/serviceStatusFor.*/'; - - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } - - foreach ($matches as $value) { - $this->assertFalse($value); - } + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + // Bulk enable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, ]); + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApisAll(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + + // Bulk disable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => false, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + + // Bulk enable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => true, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApiStatus(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + $protocols = ['rest', 'graphql', 'websocket']; + + /** + * Test for Disabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(false, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + + /** + * Test for Enabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(true, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + } + + public function testUpdateProjectApiStatusRealtimeBackwardsCompat(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + + $id = $project['body']['$id']; + + /** + * Test that "realtime" gets renamed to "websocket" via request filter + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => false, + ]); + $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ 'content-type' => 'application/json', @@ -2548,17 +2721,29 @@ class ProjectsConsoleClientTest extends Scope ])); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(false, $response['body']['protocolStatusForWebsocket']); - $matches = []; - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } + // Re-enable via old "realtime" name + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => true, + ]); - foreach ($matches as $value) { - $this->assertTrue($value); - } + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(true, $response['body']['protocolStatusForWebsocket']); } public function testUpdateProjectServiceStatusAdmin(): array @@ -2604,6 +2789,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2649,6 +2835,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $key, 'status' => true, @@ -2678,6 +2865,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2725,6 +2913,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, @@ -2752,6 +2941,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2841,6 +3031,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, From c95f905bce60eeb7682dcbf4aaee6ab07d037c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 15:58:28 +0200 Subject: [PATCH 17/92] New services and protocols tests --- tests/e2e/Services/Project/ProtocolsBase.php | 189 ++++++++++++++++++ .../Project/ProtocolsConsoleClientTest.php | 14 ++ .../Project/ProtocolsCustomServerTest.php | 14 ++ tests/e2e/Services/Project/ServicesBase.php | 189 ++++++++++++++++++ .../Project/ServicesConsoleClientTest.php | 14 ++ .../Project/ServicesCustomServerTest.php | 14 ++ 6 files changed, 434 insertions(+) create mode 100644 tests/e2e/Services/Project/ProtocolsBase.php create mode 100644 tests/e2e/Services/Project/ProtocolsConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/ProtocolsCustomServerTest.php create mode 100644 tests/e2e/Services/Project/ServicesBase.php create mode 100644 tests/e2e/Services/Project/ServicesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/ServicesCustomServerTest.php diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php new file mode 100644 index 0000000000..01b06bceb4 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -0,0 +1,189 @@ +updateProtocolStatus($protocol, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + + // Cleanup + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, true); + } + } + + public function testEnableProtocol(): void + { + // Disable first + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, false); + } + + // Re-enable + foreach (self::$protocols as $protocol) { + $response = $this->updateProtocolStatus($protocol, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + } + + public function testDisableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['protocolStatusForRest']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['protocolStatusForRest']); + } + + public function testDisabledRestBlocksClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + + $response = $this->client->call(Client::METHOD_GET, '/teams', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnabledRestAllowsClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisabledGraphqlBlocksClientRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => '{ localeListCountries { total } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testDisableOneProtocolDoesNotAffectOther(): void + { + $this->updateProtocolStatus('graphql', false); + + // REST should still work + $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testResponseModel(): void + { + $response = $this->updateProtocolStatus('rest', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('protocolStatusForRest', $response['body']); + $this->assertArrayHasKey('protocolStatusForGraphql', $response['body']); + $this->assertArrayHasKey('protocolStatusForWebsocket', $response['body']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + // Failure flow + + public function testUpdateProtocolWithoutAuthentication(): void + { + $response = $this->updateProtocolStatus('rest', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateProtocolInvalidProtocolId(): void + { + $response = $this->updateProtocolStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateProtocolEmptyProtocolId(): void + { + $response = $this->updateProtocolStatus('', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Helpers + + protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/protocols/' . $protocolId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php new file mode 100644 index 0000000000..b2cec9a438 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php @@ -0,0 +1,14 @@ +updateServiceStatus($service, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + + // Cleanup + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, true); + } + } + + public function testEnableService(): void + { + // Disable first + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, false); + } + + // Re-enable + foreach (self::$optionalServices as $service) { + $response = $this->updateServiceStatus($service, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + } + + public function testDisableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['serviceStatusForTeams']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['serviceStatusForTeams']); + } + + public function testDisabledServiceBlocksClientRequest(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/teams', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_service_disabled', $response['body']['type']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnabledServiceAllowsClientRequest(): void + { + $this->updateServiceStatus('teams', false); + $this->updateServiceStatus('teams', true); + + $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisableOneServiceDoesNotAffectOther(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testResponseModel(): void + { + $response = $this->updateServiceStatus('teams', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('serviceStatusForTeams', $response['body']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + // Failure flow + + public function testUpdateServiceWithoutAuthentication(): void + { + $response = $this->updateServiceStatus('teams', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateServiceInvalidServiceId(): void + { + $response = $this->updateServiceStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateServiceEmptyServiceId(): void + { + $response = $this->updateServiceStatus('', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Helpers + + protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/services/' . $serviceId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ServicesConsoleClientTest.php b/tests/e2e/Services/Project/ServicesConsoleClientTest.php new file mode 100644 index 0000000000..b5660607f4 --- /dev/null +++ b/tests/e2e/Services/Project/ServicesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Thu, 9 Apr 2026 16:08:11 +0200 Subject: [PATCH 18/92] Fix tests --- app/controllers/api/projects.php | 4 ++-- .../Project/Http/Project/Protocols/Status/Update.php | 2 +- tests/e2e/Services/Project/ProtocolsBase.php | 12 ++++++------ tests/e2e/Services/Project/ServicesBase.php | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 41e8777edb..5b82e6c1a3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -76,7 +76,7 @@ Http::patch('/v1/projects/:projectId/service/all') ->groups(['api', 'projects']) ->label('scope', 'projects.write') ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/api/all') @@ -84,7 +84,7 @@ Http::patch('/v1/projects/:projectId/api/all') ->groups(['api', 'projects']) ->label('scope', 'projects.write') ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/oauth2') 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 27aefca4f3..f6fe31ac56 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 @@ -51,7 +51,7 @@ class Update extends Action ) ], )) - ->param('protocolId', '', new WhiteList(array_keys(array_filter(Config::getParam('protocols'), fn ($element) => $element['optional'])), true), 'Protocol name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('protocols'), fn ($element) => $element['optional'])))) + ->param('protocolId', '', new WhiteList(array_keys(Config::getParam('protocols')), true), 'Protocol name. Can be one of: ' . \implode(', ', array_keys(Config::getParam('protocols')))) ->param('enabled', null, new Boolean(), 'Protocol status.') ->inject('response') ->inject('dbForPlatform') diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php index 01b06bceb4..5b6a1370de 100644 --- a/tests/e2e/Services/Project/ProtocolsBase.php +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -72,7 +72,7 @@ trait ProtocolsBase { $this->updateProtocolStatus('rest', false); - $response = $this->client->call(Client::METHOD_GET, '/teams', [ + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]); @@ -89,10 +89,10 @@ trait ProtocolsBase $this->updateProtocolStatus('rest', false); $this->updateProtocolStatus('rest', true); - $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ]); $this->assertSame(200, $response['headers']['status-code']); } @@ -120,10 +120,10 @@ trait ProtocolsBase $this->updateProtocolStatus('graphql', false); // REST should still work - $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ]); $this->assertSame(200, $response['headers']['status-code']); @@ -166,7 +166,7 @@ trait ProtocolsBase { $response = $this->updateProtocolStatus('', false); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(404, $response['headers']['status-code']); } // Helpers diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index b3c9a0b0c0..22e018ae8a 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -166,7 +166,7 @@ trait ServicesBase { $response = $this->updateServiceStatus('', false); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(404, $response['headers']['status-code']); } // Helpers From 21a0d60c98eae6515787e6b0d2ffa2db3579cab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 16:13:54 +0200 Subject: [PATCH 19/92] Fix tests --- app/controllers/general.php | 1 + .../Modules/Project/Http/Project/Protocols/Status/Update.php | 2 +- .../Modules/Project/Http/Project/Services/Status/Update.php | 2 +- tests/e2e/Services/Project/ProtocolsBase.php | 2 +- tests/e2e/Services/Project/ServicesBase.php | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 2a2b725262..2f89682d6f 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1438,6 +1438,7 @@ Http::error() case 402: // Error allowed publicly case 403: // Error allowed publicly case 404: // Error allowed publicly + case 405: // Error allowed publicly case 408: // Error allowed publicly case 409: // Error allowed publicly case 412: // Error allowed publicly 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 f6fe31ac56..ed331e42dc 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 @@ -27,7 +27,7 @@ class Update extends Action public function __construct() { $this - ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/project/protocols/:protocolId/status') ->httpAlias('/v1/projects/:projectId/api') ->desc('Update project protocol status') 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 21b6eff43e..9d04e503c3 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 @@ -27,7 +27,7 @@ class Update extends Action public function __construct() { $this - ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/project/services/:serviceId/status') ->httpAlias('/v1/projects/:projectId/service') ->desc('Update project service status') diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php index 5b6a1370de..ef27e5d598 100644 --- a/tests/e2e/Services/Project/ProtocolsBase.php +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -182,7 +182,7 @@ trait ProtocolsBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PUT, '/project/protocols/' . $protocolId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ 'enabled' => $enabled, ]); } diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index 22e018ae8a..6dceb6dd61 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -182,7 +182,7 @@ trait ServicesBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PUT, '/project/services/' . $serviceId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ 'enabled' => $enabled, ]); } From 5fccb8cc28502c2e521b77da08414a6f869e9f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 16:57:44 +0200 Subject: [PATCH 20/92] Improve tests --- tests/e2e/Services/Project/ProtocolsBase.php | 72 +++++++++++++++++++ tests/e2e/Services/Project/ServicesBase.php | 73 ++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php index ef27e5d598..0187fc8463 100644 --- a/tests/e2e/Services/Project/ProtocolsBase.php +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -131,6 +131,78 @@ trait ProtocolsBase $this->updateProtocolStatus('graphql', true); } + public function testDisabledRestBlocksAllServiceEndpoints(): void + { + $endpoints = [ + 'account' => '/account', + 'teams' => '/teams', + 'databases' => '/databases', + 'storage' => '/storage/buckets', + 'functions' => '/functions', + 'sites' => '/sites', + 'locale' => '/locale', + 'health' => '/health', + 'users' => '/users', + 'messaging' => '/messaging/providers', + 'migrations' => '/migrations', + ]; + + $this->updateProtocolStatus('rest', false); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Disabled REST protocol should block ' . $service . ' endpoint (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_api_disabled', $response['body']['type'], 'Disabled REST protocol should return general_api_disabled for ' . $service); + } + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testReenabledRestAllowsAllServiceEndpoints(): void + { + $endpoints = [ + 'teams' => '/teams', + 'databases' => '/databases', + 'functions' => '/functions', + 'locale' => '/locale', + ]; + + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Re-enabled REST protocol should not block ' . $service . ' endpoint'); + } + } + + public function testDisabledGraphqlBlocksMutationRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => 'mutation { teamsCreate(teamId: "unique()", name: "Test") { _id } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + public function testResponseModel(): void { $response = $this->updateProtocolStatus('rest', false); diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index 6dceb6dd61..4c0070263e 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -133,6 +133,79 @@ trait ServicesBase $this->updateServiceStatus('teams', true); } + public function testEachDisabledServiceBlocksItsEndpoint(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'graphql' => ['method' => Client::METHOD_POST, 'path' => '/graphql'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Service ' . $service . ' should block requests when disabled (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_service_disabled', $response['body']['type'], 'Service ' . $service . ' should return general_service_disabled error type'); + + // Cleanup + $this->updateServiceStatus($service, true); + } + } + + public function testEachReenabledServiceAllowsRequest(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'graphql' => ['method' => Client::METHOD_POST, 'path' => '/graphql'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + $this->updateServiceStatus($service, true); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Service ' . $service . ' should allow requests after re-enabling'); + } + } + public function testResponseModel(): void { $response = $this->updateServiceStatus('teams', false); From d69726487ec520010af2bf0123492786edf69ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 Apr 2026 16:58:42 +0200 Subject: [PATCH 21/92] PR review fixes --- .../Project/Http/Project/Protocols/Status/Update.php | 4 +++- .../Project/Http/Project/Services/Status/Update.php | 4 +++- src/Appwrite/Utopia/Response/Filters/V22.php | 10 ++++++++++ 3 files changed, 16 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 ed331e42dc..1fa2df3566 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 @@ -71,7 +71,9 @@ class Update extends Action $protocols = $project->getAttribute('apis', []); $protocols[$protocolId] = $enabled; - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $protocols))); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'apis' => $protocols, + ]))); $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 9d04e503c3..35be32a604 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 @@ -71,7 +71,9 @@ class Update extends Action $services = $project->getAttribute('services', []); $services[$serviceId] = $enabled; - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services))); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'services' => $services, + ]))); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Utopia/Response/Filters/V22.php b/src/Appwrite/Utopia/Response/Filters/V22.php index 35a8ea7937..67011e2d03 100644 --- a/src/Appwrite/Utopia/Response/Filters/V22.php +++ b/src/Appwrite/Utopia/Response/Filters/V22.php @@ -2,6 +2,7 @@ namespace Appwrite\Utopia\Response\Filters; +use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filter; // Convert 1.9.1 Data format to 1.9.0 format @@ -10,7 +11,16 @@ class V22 extends Filter public function parse(array $content, string $model): array { return match ($model) { + Response::MODEL_PROJECT => $this->parseProject($content), default => $content, }; } + + private function parseProject(array $content): array + { + foreach (['protocolStatusForRest', 'protocolStatusForGraphql', 'protocolStatusForWebsocket'] as $field) { + unset($content[$field]); + } + return $content; + } } From d13dbae0feb7ad909cb3a86ece706f80253aac15 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 10 Apr 2026 13:59:45 +0530 Subject: [PATCH 22/92] 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 23/92] 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 24/92] 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 25/92] 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 26/92] 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 27/92] 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 28/92] 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 29/92] 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 ec637d44171be38f39c213a34fcc8531ebca819b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 11 Apr 2026 10:19:14 +0200 Subject: [PATCH 30/92] Mark key scopes required --- .../Modules/Project/Http/Project/Keys/Create.php | 9 +++------ .../Modules/Project/Http/Project/Keys/Update.php | 9 +++------ src/Appwrite/Utopia/Request/Filters/V22.php | 13 +++++++++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 59d2c1db49..236c091c31 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -62,7 +62,7 @@ class Create extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -72,13 +72,10 @@ class Create extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -95,7 +92,7 @@ class Create extends Base 'resourceId' => $project->getId(), 'resourceType' => 'projects', 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 8759faacc1..9193bdbfdf 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -60,7 +60,7 @@ class Update extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -70,13 +70,10 @@ class Update extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -92,7 +89,7 @@ class Update extends Base $updates = new Document([ 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, ]); diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 837ded5905..2e1bdf292b 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -41,6 +41,15 @@ class V22 extends Filter return $content; } + protected function parseKeyScopes(array $content): array + { + if (!\is_array($content['scopes'] ?? null)) { + $content['scopes'] = []; + } + + return $content; + } + public function parse(array $content, string $model): array { switch ($model) { @@ -50,6 +59,10 @@ class V22 extends Filter case 'project.updateProtocolStatus': $content = $this->parseUpdateProtocolStatus($content); break; + case 'project.createKey': + case 'project.updateKey': + $content = $this->parseKeyScopes($content); + break; } return $content; } From fabd9559c4843a0195a7346d08d4100af537811f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 11 Apr 2026 10:22:03 +0200 Subject: [PATCH 31/92] Tests for backwards compatibility --- tests/e2e/Services/Project/KeysBase.php | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index fda5ef377f..48c584a374 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -93,6 +93,58 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } + public function testCreateKeyWithNullScopesV22BackwardCompat(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $key = $this->client->call(Client::METHOD_POST, '/project/keys', $headers, [ + 'keyId' => ID::unique(), + 'name' => 'V22 Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testUpdateKeyWithNullScopesV22BackwardCompat(): void + { + $key = $this->createKey( + ID::unique(), + 'V22 Update Compat Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, [ + 'name' => 'V22 Update Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame([], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + public function testCreateKeyWithoutAuthentication(): void { $response = $this->createKey( From 18d17ea945553a5d2ab93b8796b60c38323f5575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 11 Apr 2026 11:00:56 +0200 Subject: [PATCH 32/92] Webhook endpoints quality improvements --- .../Modules/Webhooks/Http/Webhooks/Create.php | 18 +++++------ .../Http/Webhooks/Signature/Update.php | 8 ++--- .../Modules/Webhooks/Http/Webhooks/Update.php | 18 +++++------ .../Modules/Webhooks/Http/Webhooks/XList.php | 9 ++++++ .../Database/Validator/Queries/Webhooks.php | 4 +-- src/Appwrite/Utopia/Request/Filters/V22.php | 24 +++++++++++++++ src/Appwrite/Utopia/Response/Filters/V22.php | 19 ++++++++++++ .../Utopia/Response/Model/Webhook.php | 30 +++++++++++++++---- 8 files changed, 100 insertions(+), 30 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index 3c716202af..e60810417c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -65,9 +65,9 @@ class Create extends Action ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) - ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -85,9 +85,9 @@ class Create extends Action string $name, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, Response $response, Document $project, QueueEvent $queueForEvents, @@ -104,9 +104,9 @@ class Create extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, 'signatureKey' => \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php index 51c5bfbaf9..e36fa5c2ce 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -28,9 +28,9 @@ class Update extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/webhooks/:webhookId/signature') + ->setHttpPath('/v1/webhooks/:webhookId/secret') ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature') - ->desc('Update webhook signature key') + ->desc('Update webhook secret key') ->groups(['api', 'webhooks']) ->label('scope', 'webhooks.write') ->label('event', 'webhooks.[webhookId].update') @@ -39,9 +39,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'webhooks', group: null, - name: 'updateSignature', + name: 'updateSecret', description: <<param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.') ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) - ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -80,9 +80,9 @@ class Update extends Action string $url, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, Response $response, Document $project, QueueEvent $queueForEvents, @@ -102,9 +102,9 @@ class Update extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 2a4c4f9e59..763c0d339b 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -78,6 +78,15 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + foreach ($queries as $query) { + $attribute = $query->getAttribute(); + if ($attribute === 'authUsername') { + $query->setAttribute('httpUser'); + } elseif ($attribute === 'tls') { + $query->setAttribute('security'); + } + } + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); $cursor = Query::getCursorQueries($queries, false); diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index fa20bf34ef..42d5166909 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -7,8 +7,8 @@ class Webhooks extends Base public const ALLOWED_ATTRIBUTES = [ 'name', 'url', - 'httpUser', - 'security', + 'authUsername', + 'tls', 'events', 'enabled', 'logs', diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 2e1bdf292b..4f1e746775 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -50,6 +50,26 @@ class V22 extends Filter return $content; } + protected function parseWebhook(array $content): array + { + if (isset($content['security'])) { + $content['tls'] = $content['security']; + unset($content['security']); + } + + if (isset($content['httpUser'])) { + $content['authUsername'] = $content['httpUser']; + unset($content['httpUser']); + } + + if (isset($content['httpPass'])) { + $content['authPassword'] = $content['httpPass']; + unset($content['httpPass']); + } + + return $content; + } + public function parse(array $content, string $model): array { switch ($model) { @@ -63,6 +83,10 @@ class V22 extends Filter case 'project.updateKey': $content = $this->parseKeyScopes($content); break; + case 'webhooks.create': + case 'webhooks.update': + $content = $this->parseWebhook($content); + break; } return $content; } diff --git a/src/Appwrite/Utopia/Response/Filters/V22.php b/src/Appwrite/Utopia/Response/Filters/V22.php index 67011e2d03..4e295e43dd 100644 --- a/src/Appwrite/Utopia/Response/Filters/V22.php +++ b/src/Appwrite/Utopia/Response/Filters/V22.php @@ -12,6 +12,8 @@ class V22 extends Filter { return match ($model) { Response::MODEL_PROJECT => $this->parseProject($content), + Response::MODEL_WEBHOOK => $this->parseWebhook($content), + Response::MODEL_WEBHOOK_LIST => $this->handleList($content, 'webhooks', fn ($item) => $this->parseWebhook($item)), default => $content, }; } @@ -23,4 +25,21 @@ class V22 extends Filter } return $content; } + + private function parseWebhook(array $content): array + { + $content['security'] = $content['tls'] ?? true; + unset($content['tls']); + + $content['httpUser'] = $content['authUsername'] ?? ''; + unset($content['authUsername']); + + $content['httpPass'] = $content['authPassword'] ?? ''; + unset($content['authPassword']); + + $content['signatureKey'] = $content['secret'] ?? ''; + unset($content['secret']); + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 1ae8d5cb7b..c2a2c183b1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -4,6 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class Webhook extends Model { @@ -50,27 +51,27 @@ class Webhook extends Model ], 'array' => true, ]) - ->addRule('security', [ + ->addRule('tls', [ 'type' => self::TYPE_BOOLEAN, - 'description' => 'Indicated if SSL / TLS Certificate verification is enabled.', + 'description' => 'Indicates if SSL / TLS certificate verification is enabled.', 'default' => true, 'example' => true, ]) - ->addRule('httpUser', [ + ->addRule('authUsername', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication username.', 'default' => '', 'example' => 'username', ]) - ->addRule('httpPass', [ + ->addRule('authPassword', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication password.', 'default' => '', 'example' => 'password', ]) - ->addRule('signatureKey', [ + ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validated incoming', + 'description' => 'Signature key which can be used to validate incoming webhook payloads.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) @@ -94,6 +95,23 @@ class Webhook extends Model ]); } + public function filter(Document $document): Document + { + $document->setAttribute('tls', $document->getAttribute('security')); + $document->removeAttribute('security'); + + $document->setAttribute('authUsername', $document->getAttribute('httpUser')); + $document->removeAttribute('httpUser'); + + $document->setAttribute('authPassword', $document->getAttribute('httpPass')); + $document->removeAttribute('httpPass'); + + $document->setAttribute('secret', $document->getAttribute('signatureKey')); + $document->removeAttribute('signatureKey'); + + return $document; + } + /** * Get Name * From a1267b1bff4cc9e3a06790341032a459e514fbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 11 Apr 2026 11:16:43 +0200 Subject: [PATCH 33/92] Backwards compatibiltiy tests --- tests/e2e/Services/Webhooks/WebhooksBase.php | 382 +++++++++++++++---- 1 file changed, 315 insertions(+), 67 deletions(-) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 7ad701b564..82181aa858 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -35,11 +35,11 @@ trait WebhooksBase $this->assertContains('users.*.create', $webhook['body']['events']); $this->assertCount(1, $webhook['body']['events']); $this->assertEquals(true, $webhook['body']['enabled']); - $this->assertEquals(false, $webhook['body']['security']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); - $this->assertNotEmpty($webhook['body']['signatureKey']); - $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + $this->assertEquals(false, $webhook['body']['tls']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); + $this->assertNotEmpty($webhook['body']['secret']); + $this->assertEquals(128, \strlen($webhook['body']['secret'])); $this->assertEquals(0, $webhook['body']['attempts']); $this->assertEquals('', $webhook['body']['logs']); @@ -63,11 +63,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testCreateWebhookWithSecurity(): void + public function testCreateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Webhook With Security', + 'Webhook With TLS', ['users.*.create'], null, 'https://appwrite.io', @@ -78,8 +78,8 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals(true, $webhook['body']['security']); - $this->assertIsBool($webhook['body']['security']); + $this->assertEquals(true, $webhook['body']['tls']); + $this->assertIsBool($webhook['body']['tls']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -100,14 +100,14 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals('username', $webhook['body']['httpUser']); - $this->assertEquals('password', $webhook['body']['httpPass']); - $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('username', $webhook['body']['authUsername']); + $this->assertEquals('password', $webhook['body']['authPassword']); + $this->assertEquals(true, $webhook['body']['tls']); // Verify via GET $get = $this->getWebhook($webhook['body']['$id']); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('username', $get['body']['httpUser']); + $this->assertEquals('username', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -331,11 +331,11 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } - public function testUpdateWebhookWithSecurity(): void + public function testUpdateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Webhook', + 'TLS Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -345,7 +345,7 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals(false, $webhook['body']['security']); + $this->assertEquals(false, $webhook['body']['tls']); $webhookId = $webhook['body']['$id']; // Update to enable security @@ -361,8 +361,8 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertIsBool($updated['body']['security']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertIsBool($updated['body']['tls']); // Cleanup $this->deleteWebhook($webhookId); @@ -382,8 +382,8 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); $webhookId = $webhook['body']['$id']; // Update with HTTP auth credentials @@ -399,13 +399,13 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals('newuser', $updated['body']['httpUser']); - $this->assertEquals('newpass', $updated['body']['httpPass']); + $this->assertEquals('newuser', $updated['body']['authUsername']); + $this->assertEquals('newpass', $updated['body']['authPassword']); // Verify via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('newuser', $get['body']['httpUser']); + $this->assertEquals('newuser', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhookId); @@ -657,19 +657,19 @@ trait WebhooksBase $this->assertContains('buckets.*.files.*.create', $updated['body']['events']); $this->assertCount(3, $updated['body']['events']); $this->assertEquals('https://appwrite.io/updated', $updated['body']['url']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertEquals('user', $updated['body']['httpUser']); - $this->assertEquals('pass', $updated['body']['httpPass']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertEquals('user', $updated['body']['authUsername']); + $this->assertEquals('pass', $updated['body']['authPassword']); // Cleanup $this->deleteWebhook($webhookId); } - public function testUpdateWebhookSignature(): void + public function testUpdateWebhookSecret(): void { $webhook = $this->createWebhook( ID::unique(), - 'Signature Webhook', + 'Secret Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -680,27 +680,27 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $webhookId = $webhook['body']['$id']; - $originalSignatureKey = $webhook['body']['signatureKey']; + $originalSecret = $webhook['body']['secret']; - $this->assertNotEmpty($originalSignatureKey); - $this->assertEquals(128, \strlen($originalSignatureKey)); + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); - // Update signature - $updated = $this->updateWebhookSignature($webhookId); + // Update secret + $updated = $this->updateWebhookSecret($webhookId); $this->assertEquals(200, $updated['headers']['status-code']); $this->assertEquals($webhookId, $updated['body']['$id']); - $this->assertNotEmpty($updated['body']['signatureKey']); - $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); - $this->assertNotEquals($originalSignatureKey, $updated['body']['signatureKey']); + $this->assertNotEmpty($updated['body']['secret']); + $this->assertEquals(128, \strlen($updated['body']['secret'])); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new signature persisted via GET + // Verify new secret persisted via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSignatureKey, $get['body']['signatureKey']); + $this->assertNotEquals($originalSecret, $get['body']['secret']); - // Test signature update on non-existent webhook - $notFound = $this->updateWebhookSignature('non-existent-id'); + // Test secret update on non-existent webhook + $notFound = $this->updateWebhookSecret('non-existent-id'); $this->assertEquals(404, $notFound['headers']['status-code']); $this->assertEquals('webhook_not_found', $notFound['body']['type']); @@ -934,11 +934,11 @@ trait WebhooksBase $this->assertContains('users.*.update.email', $get['body']['events']); $this->assertCount(2, $get['body']['events']); $this->assertEquals(true, $get['body']['enabled']); - $this->assertEquals(true, $get['body']['security']); - $this->assertEquals('myuser', $get['body']['httpUser']); - $this->assertEquals('mypass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); - $this->assertEquals(128, \strlen($get['body']['signatureKey'])); + $this->assertEquals(true, $get['body']['tls']); + $this->assertEquals('myuser', $get['body']['authUsername']); + $this->assertEquals('mypass', $get['body']['authPassword']); + $this->assertNotEmpty($get['body']['secret']); + $this->assertEquals(128, \strlen($get['body']['secret'])); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1043,9 +1043,9 @@ trait WebhooksBase $this->assertArrayHasKey('name', $webhook); $this->assertArrayHasKey('url', $webhook); $this->assertArrayHasKey('events', $webhook); - $this->assertArrayHasKey('security', $webhook); + $this->assertArrayHasKey('tls', $webhook); $this->assertArrayHasKey('enabled', $webhook); - $this->assertArrayHasKey('signatureKey', $webhook); + $this->assertArrayHasKey('secret', $webhook); $this->assertArrayHasKey('attempts', $webhook); $this->assertArrayHasKey('logs', $webhook); } @@ -1247,11 +1247,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testListWebhooksFilterBySecurity(): void + public function testListWebhooksFilterByTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Filter Webhook', + 'TLS Filter Webhook', ['users.*.create'], null, 'https://appwrite.io/sec', @@ -1262,13 +1262,13 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $list = $this->listWebhooks([ - Query::equal('security', [true])->toString(), + Query::equal('tls', [true])->toString(), ], true); $this->assertEquals(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); foreach ($list['body']['webhooks'] as $w) { - $this->assertEquals(true, $w['security']); + $this->assertEquals(true, $w['tls']); } // Cleanup @@ -1503,6 +1503,254 @@ trait WebhooksBase $this->assertEquals('webhook_not_found', $delete['body']['type']); } + // ========================================================================= + // Backward compatibility tests (1.9.0 response format) + // ========================================================================= + + public function testCreateWebhookV22BackwardCompatRequest(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Send old param names with 1.9.0 header + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', $headers, [ + 'webhookId' => ID::unique(), + 'name' => 'V22 Compat Create', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'olduser', + 'httpPass' => 'oldpass', + ]); + + $this->assertEquals(201, $webhook['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $webhook['body']); + $this->assertArrayHasKey('httpUser', $webhook['body']); + $this->assertArrayHasKey('httpPass', $webhook['body']); + $this->assertArrayHasKey('signatureKey', $webhook['body']); + + // New field names should NOT be present + $this->assertArrayNotHasKey('tls', $webhook['body']); + $this->assertArrayNotHasKey('authUsername', $webhook['body']); + $this->assertArrayNotHasKey('authPassword', $webhook['body']); + $this->assertArrayNotHasKey('secret', $webhook['body']); + + // Values should be correct + $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('olduser', $webhook['body']['httpUser']); + $this->assertEquals('oldpass', $webhook['body']['httpPass']); + $this->assertNotEmpty($webhook['body']['signatureKey']); + $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testUpdateWebhookV22BackwardCompatRequest(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Update', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Update using old param names + $updated = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, $headers, [ + 'name' => 'V22 Compat Updated', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'updateduser', + 'httpPass' => 'updatedpass', + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + $this->assertArrayHasKey('signatureKey', $updated['body']); + + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + $this->assertArrayNotHasKey('secret', $updated['body']); + + $this->assertEquals(true, $updated['body']['security']); + $this->assertEquals('updateduser', $updated['body']['httpUser']); + $this->assertEquals('updatedpass', $updated['body']['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testGetWebhookV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Get', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'getuser', + 'getpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // GET with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $get = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, $headers); + + $this->assertEquals(200, $get['headers']['status-code']); + + // Should have old field names + $this->assertArrayHasKey('security', $get['body']); + $this->assertArrayHasKey('httpUser', $get['body']); + $this->assertArrayHasKey('httpPass', $get['body']); + $this->assertArrayHasKey('signatureKey', $get['body']); + + $this->assertArrayNotHasKey('tls', $get['body']); + $this->assertArrayNotHasKey('authUsername', $get['body']); + $this->assertArrayNotHasKey('authPassword', $get['body']); + $this->assertArrayNotHasKey('secret', $get['body']); + + $this->assertEquals(true, $get['body']['security']); + $this->assertEquals('getuser', $get['body']['httpUser']); + $this->assertEquals('getpass', $get['body']['httpPass']); + $this->assertNotEmpty($get['body']['signatureKey']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testListWebhooksV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat List', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'listuser', + 'listpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // LIST with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $list = $this->client->call(Client::METHOD_GET, '/webhooks', $headers, [ + 'queries' => [ + Query::equal('name', ['V22 Compat List'])->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(1, $list['body']['total']); + $this->assertCount(1, $list['body']['webhooks']); + + $item = $list['body']['webhooks'][0]; + + // Each item should have old field names + $this->assertArrayHasKey('security', $item); + $this->assertArrayHasKey('httpUser', $item); + $this->assertArrayHasKey('httpPass', $item); + $this->assertArrayHasKey('signatureKey', $item); + + $this->assertArrayNotHasKey('tls', $item); + $this->assertArrayNotHasKey('authUsername', $item); + $this->assertArrayNotHasKey('authPassword', $item); + $this->assertArrayNotHasKey('secret', $item); + + $this->assertEquals(true, $item['security']); + $this->assertEquals('listuser', $item['httpUser']); + $this->assertEquals('listpass', $item['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Secret', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Update secret with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', $headers); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use old field names + $this->assertArrayHasKey('signatureKey', $updated['body']); + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + + $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + + $this->assertNotEmpty($updated['body']['signatureKey']); + $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // Helpers /** @@ -1531,7 +1779,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed { $params = [ 'webhookId' => $webhookId, @@ -1543,14 +1791,14 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ @@ -1561,7 +1809,7 @@ trait WebhooksBase return $webhook; } - protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed { $params = [ 'name' => $name, @@ -1572,14 +1820,14 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; } $webhook = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ @@ -1590,9 +1838,9 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSignature(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId): mixed { - $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); From c5bd8c712f76e96af7f960d2d8a7f638c9163b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 11 Apr 2026 11:31:35 +0200 Subject: [PATCH 34/92] Upgrade libs --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 164b3a036f..3d7800a919 100644 --- a/composer.lock +++ b/composer.lock @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.50", + "version": "3.0.51", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-04-10T01:33:53+00:00" }, { "name": "psr/clock", From 27fc8058b9c0439fb951205212cacd84e1ef02b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 11 Apr 2026 14:19:05 +0200 Subject: [PATCH 35/92] Fix failing tests --- app/controllers/shared/api.php | 19 ++++--- .../Database/Validator/Queries/Webhooks.php | 44 ++++++++++++++- tests/e2e/Scopes/ProjectCustom.php | 4 +- tests/e2e/Services/Project/KeysBase.php | 6 +- tests/e2e/Services/Project/ServicesBase.php | 3 - tests/e2e/Services/Projects/ProjectsBase.php | 8 +-- .../Projects/ProjectsConsoleClientTest.php | 56 +++++++++---------- 7 files changed, 90 insertions(+), 50 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 11ac345fca..9a786c2d18 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -424,7 +424,7 @@ Http::init() } if (! empty($method)) { - $namespace = $method->getNamespace(); + $namespace = \strtolower($method->getNamespace()); if ( array_key_exists($namespace, $project->getAttribute('services', [])) @@ -435,6 +435,15 @@ Http::init() } } + // Step 8b: Check REST protocol status + if ( + array_key_exists('rest', $project->getAttribute('apis', [])) + && ! $project->getAttribute('apis', [])['rest'] + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + ) { + throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); + } + // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { @@ -510,14 +519,6 @@ Http::init() default => '', }; - if ( - array_key_exists('rest', $project->getAttribute('apis', [])) - && ! $project->getAttribute('apis', [])['rest'] - && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) - ) { - throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); - } - /* * Abuse Check */ diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index 42d5166909..07e27f06cb 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -15,12 +15,54 @@ class Webhooks extends Base 'attempts', ]; + /** + * Map API attribute names to DB column names. + */ + private const ATTRIBUTE_ALIASES = [ + 'tls' => 'security', + 'authUsername' => 'httpUser', + ]; + + /** + * DB column names used for schema validation. + */ + private const DB_ATTRIBUTES = [ + 'name', + 'url', + 'httpUser', + 'security', + 'events', + 'enabled', + 'logs', + 'attempts', + ]; + /** * Expression constructor * */ public function __construct() { - parent::__construct('webhooks', self::ALLOWED_ATTRIBUTES); + parent::__construct('webhooks', self::DB_ATTRIBUTES); + } + + /** + * Convert API attribute names to DB column names in query strings before validation. + */ + public function isValid($value): bool + { + if (\is_array($value)) { + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); + } + } + unset($queryString); + } + + return parent::isValid($value); } } diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 10641019f0..a62a1e8ba3 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -216,7 +216,7 @@ trait ProjectCustom 'users.*' ], 'url' => 'http://request-catcher-webhook:5000/', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(201, $webhook['headers']['status-code']); @@ -243,7 +243,7 @@ trait ProjectCustom 'apiKey' => $key['body']['secret'], 'devKey' => $devKey['body']['secret'], 'webhookId' => $webhook['body']['$id'], - 'signatureKey' => $webhook['body']['signatureKey'], + 'signatureKey' => $webhook['body']['secret'], ]; } diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 48c584a374..505c7f6539 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -78,12 +78,12 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } - public function testCreateKeyWithNullScopes(): void + public function testCreateKeyWithEmptyScopes(): void { $key = $this->createKey( ID::unique(), - 'Null Scopes Key', - null, + 'Empty Scopes Key', + [], ); $this->assertSame(201, $key['headers']['status-code']); diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index 4c0070263e..1bc7ce5042 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -24,7 +24,6 @@ trait ServicesBase 'sites', 'functions', 'proxy', - 'graphql', 'migrations', 'messaging', ]; @@ -150,7 +149,6 @@ trait ServicesBase 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], - 'graphql' => ['method' => Client::METHOD_POST, 'path' => '/graphql'], 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], ]; @@ -188,7 +186,6 @@ trait ServicesBase 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], - 'graphql' => ['method' => Client::METHOD_POST, 'path' => '/graphql'], 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], ]; diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 6122e95c75..122104e3d9 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -90,16 +90,16 @@ trait ProjectsBase 'name' => 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); self::$cachedProjectWithWebhook = array_merge($projectData, [ 'webhookId' => $response['body']['$id'], - 'signatureKey' => $response['body']['signatureKey'] + 'signatureKey' => $response['body']['secret'] ]); return self::$cachedProjectWithWebhook; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 096d90d8d2..7b9848e38f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3053,9 +3053,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -3064,9 +3064,9 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(true, $response['body']['security']); - $this->assertEquals('username', $response['body']['httpUser']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(true, $response['body']['tls']); + $this->assertEquals('username', $response['body']['authUsername']); /** * Test for FAILURE @@ -3080,9 +3080,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['account.unknown', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3140,8 +3140,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertEquals('username', $response['body']['httpUser']); - $this->assertEquals('password', $response['body']['httpPass']); + $this->assertEquals('username', $response['body']['authUsername']); + $this->assertEquals('password', $response['body']['authPassword']); /** * Test for FAILURE @@ -3169,7 +3169,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -3181,10 +3181,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', @@ -3201,10 +3201,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); /** * Test for FAILURE @@ -3217,7 +3217,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.unknown'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3230,7 +3230,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3255,15 +3255,15 @@ class ProjectsConsoleClientTest extends Scope $webhookId = $data['webhookId']; $signatureKey = $data['signatureKey']; - $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, 'x-appwrite-mode' => 'admin' ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['signatureKey']); - $this->assertNotEquals($signatureKey, $response['body']['signatureKey']); + $this->assertNotEmpty($response['body']['secret']); + $this->assertNotEquals($signatureKey, $response['body']['secret']); } public function testDeleteProjectWebhook(): void @@ -3282,9 +3282,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook To Delete', 'events' => ['users.*.create'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); From 9ba182d8a0aeff8980952862bfe90021297a7bed Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sun, 12 Apr 2026 03:22:21 +1200 Subject: [PATCH 36/92] (fix): cache fallback --- .../Databases/Collections/Documents/XList.php | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index c35eebaea2..0fe628e51e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -140,44 +140,49 @@ class XList extends Action $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; } elseif ((int)$ttl > 0) { - $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); - $roles = $dbForProject->getAuthorization()->getRoles(); - $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); + try { + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); + $roles = $dbForProject->getAuthorization()->getRoles(); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - $documentsCacheHit = false; - $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + $documentsCacheHit = false; + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); - if ($cachedDocuments !== null && - $cachedDocuments !== false && - \is_array($cachedDocuments)) { - $documents = \array_map(function ($doc) { - return new Document($doc); - }, $cachedDocuments); - $documentsCacheHit = true; - } else { - $documents = $find(); - - // Convert Document objects to arrays for caching - $documentsArray = \array_map(function ($doc) { - return $doc->getArrayCopy(); - }, $documents); - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); - } - - if ($includeTotal) { - $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); - if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; } else { - $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($cacheKey, $total, $totalField); - } - } else { - $total = 0; - } + $documents = $find(); - $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + } + + if ($includeTotal) { + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = $cachedTotal; + } else { + $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + } catch (\Throwable) { + $documents = $find(); + $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $response->addHeader('X-Appwrite-Cache', 'error'); + } } else { $documents = $find(); $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; From a26382ac513e16da4f264f6a5864cb1793f2bf74 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sun, 12 Apr 2026 03:25:07 +1200 Subject: [PATCH 37/92] (chore): lockfile --- composer.lock | 130 +++++++++++++++++++++++++------------------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/composer.lock b/composer.lock index 164b3a036f..832d3c25f5 100644 --- a/composer.lock +++ b/composer.lock @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.50", + "version": "3.0.51", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-04-10T01:33:53+00:00" }, { "name": "psr/clock", @@ -2887,16 +2887,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" }, "funding": [ { @@ -2968,20 +2968,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "5d2ed36f7734637dacc025f179698031951b1692" + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", - "reference": "5d2ed36f7734637dacc025f179698031951b1692", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", "shasum": "" }, "require": { @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0" }, "funding": [ { @@ -3048,20 +3048,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0" }, "funding": [ { @@ -3128,20 +3128,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "2c408a6bb0313e6001a83628dc5506100474254e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", + "reference": "2c408a6bb0313e6001a83628dc5506100474254e", "shasum": "" }, "require": { @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0" }, "funding": [ { @@ -3208,7 +3208,7 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/service-contracts", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.19", + "version": "5.3.20", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" + "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24" }, "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/fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", + "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", "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/5.3.20" }, - "time": "2026-03-31T15:52:08+00:00" + "time": "2026-04-10T08:27:41+00:00" }, { "name": "utopia-php/detector", @@ -5225,16 +5225,16 @@ }, { "name": "utopia-php/vcs", - "version": "3.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af" + "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3", + "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3", "shasum": "" }, "require": { @@ -5268,9 +5268,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/3.1.0" + "source": "https://github.com/utopia-php/vcs/tree/3.2.0" }, - "time": "2026-03-24T08:49:14+00:00" + "time": "2026-04-08T16:00:31+00:00" }, { "name": "utopia-php/websocket", @@ -5448,16 +5448,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.7", + "version": "1.17.11", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e" + "reference": "c714ee52659ef5968b3372ff4da0e407140a6250" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/291471d04c3f0e7b9fcc46668a6255a4c0f2947e", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/c714ee52659ef5968b3372ff4da0e407140a6250", + "reference": "c714ee52659ef5968b3372ff4da0e407140a6250", "shasum": "" }, "require": { @@ -5493,9 +5493,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.7" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.11" }, - "time": "2026-04-08T08:51:05+00:00" + "time": "2026-04-11T02:42:32+00:00" }, { "name": "brianium/paratest", @@ -7764,16 +7764,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -7823,7 +7823,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0" }, "funding": [ { @@ -7843,20 +7843,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", "shasum": "" }, "require": { @@ -7905,7 +7905,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0" }, "funding": [ { @@ -7925,11 +7925,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -7990,7 +7990,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0" }, "funding": [ { @@ -8014,7 +8014,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8070,7 +8070,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0" }, "funding": [ { @@ -8449,5 +8449,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From e3ad0f85de329e4f73f86577be1d9bd165c83e4b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sun, 12 Apr 2026 03:42:47 +1200 Subject: [PATCH 38/92] fix: narrow cache try-catch to avoid swallowing query exceptions Wrap only cache load/save calls in try-catch instead of the entire cache block. This prevents OrderException, QueryException, and Timeout from $find() being caught and retried, which would double DB calls and hide real query errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Databases/Collections/Documents/XList.php | 86 ++++++++++--------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 0fe628e51e..aeee280615 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -140,49 +140,57 @@ class XList extends Action $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; } elseif ((int)$ttl > 0) { + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); + $roles = $dbForProject->getAuthorization()->getRoles(); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); + + $documentsCacheHit = false; try { - $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); - $roles = $dbForProject->getAuthorization()->getRoles(); - $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - - $documentsCacheHit = false; $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); - - if ($cachedDocuments !== null && - $cachedDocuments !== false && - \is_array($cachedDocuments)) { - $documents = \array_map(function ($doc) { - return new Document($doc); - }, $cachedDocuments); - $documentsCacheHit = true; - } else { - $documents = $find(); - - $documentsArray = \array_map(function ($doc) { - return $doc->getArrayCopy(); - }, $documents); - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); - } - - if ($includeTotal) { - $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); - if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; - } else { - $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($cacheKey, $total, $totalField); - } - } else { - $total = 0; - } - - $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } catch (\Throwable) { - $documents = $find(); - $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; - $response->addHeader('X-Appwrite-Cache', 'error'); + $cachedDocuments = null; } + + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; + } else { + $documents = $find(); + + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + try { + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + } catch (\Throwable) { + } + } + + if ($includeTotal) { + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + try { + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + } catch (\Throwable) { + $cachedTotal = null; + } + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = $cachedTotal; + } else { + $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); + try { + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } catch (\Throwable) { + } + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { $documents = $find(); $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; From 2ee2ea09a0fbbbf1276158e06f84710f450869ce Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 12 Apr 2026 13:56:49 +0530 Subject: [PATCH 39/92] fix(installer): sync compose template executor image --- app/views/install/compose.phtml | 2 +- .../Modules/Installer/ComposeTemplateTest.php | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 8d0ae55394..ef4d4a1fe4 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -993,7 +993,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); <<: *x-logging restart: unless-stopped stop_signal: SIGINT - image: openruntimes/executor:0.7.22 + image: openruntimes/executor:0.11.4 networks: - appwrite - runtimes diff --git a/tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php b/tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php new file mode 100644 index 0000000000..d2588835ff --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php @@ -0,0 +1,67 @@ +readRepoFile('/docker-compose.yml'); + $renderedCompose = $this->renderInstallerCompose(); + + $this->assertStringContainsString("appwrite-worker-executions:\n", $canonicalCompose); + $this->assertStringContainsString("appwrite-worker-executions:\n", $renderedCompose); + $this->assertStringContainsString("entrypoint: worker-executions\n", $renderedCompose); + } + + public function testRenderedInstallerComposeUsesCanonicalExecutorImage(): void + { + $canonicalExecutorImage = $this->extractExecutorImage($this->readRepoFile('/docker-compose.yml')); + $renderedCompose = $this->renderInstallerCompose(); + + $this->assertStringContainsString("image: {$canonicalExecutorImage}\n", $renderedCompose); + } + + private function renderInstallerCompose(): string + { + $view = new View($this->repoPath('/app/views/install/compose.phtml')); + + $view + ->setParam('httpPort', '80') + ->setParam('httpsPort', '443') + ->setParam('version', '1.9.0') + ->setParam('organization', 'appwrite') + ->setParam('image', 'appwrite') + ->setParam('database', 'mariadb') + ->setParam('hostPath', '') + ->setParam('enableAssistant', false); + + return $view->render(false); + } + + private function extractExecutorImage(string $compose): string + { + $matched = preg_match('/^\s*image:\s*(openruntimes\/executor:[^\s]+)\s*$/m', $compose, $matches); + + $this->assertSame(1, $matched, 'Failed to find the canonical openruntimes executor image.'); + + return $matches[1]; + } + + private function readRepoFile(string $path): string + { + $contents = file_get_contents($this->repoPath($path)); + + $this->assertIsString($contents, "Failed to read {$path}."); + + return $contents; + } + + private function repoPath(string $path): string + { + return dirname(__DIR__, 5) . $path; + } +} From cb4c97f2eebeb76859379b3f262ccd1c6fae0404 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 12 Apr 2026 14:11:51 +0530 Subject: [PATCH 40/92] chore: remove installer compose regression test --- .../Modules/Installer/ComposeTemplateTest.php | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php diff --git a/tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php b/tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php deleted file mode 100644 index d2588835ff..0000000000 --- a/tests/unit/Platform/Modules/Installer/ComposeTemplateTest.php +++ /dev/null @@ -1,67 +0,0 @@ -readRepoFile('/docker-compose.yml'); - $renderedCompose = $this->renderInstallerCompose(); - - $this->assertStringContainsString("appwrite-worker-executions:\n", $canonicalCompose); - $this->assertStringContainsString("appwrite-worker-executions:\n", $renderedCompose); - $this->assertStringContainsString("entrypoint: worker-executions\n", $renderedCompose); - } - - public function testRenderedInstallerComposeUsesCanonicalExecutorImage(): void - { - $canonicalExecutorImage = $this->extractExecutorImage($this->readRepoFile('/docker-compose.yml')); - $renderedCompose = $this->renderInstallerCompose(); - - $this->assertStringContainsString("image: {$canonicalExecutorImage}\n", $renderedCompose); - } - - private function renderInstallerCompose(): string - { - $view = new View($this->repoPath('/app/views/install/compose.phtml')); - - $view - ->setParam('httpPort', '80') - ->setParam('httpsPort', '443') - ->setParam('version', '1.9.0') - ->setParam('organization', 'appwrite') - ->setParam('image', 'appwrite') - ->setParam('database', 'mariadb') - ->setParam('hostPath', '') - ->setParam('enableAssistant', false); - - return $view->render(false); - } - - private function extractExecutorImage(string $compose): string - { - $matched = preg_match('/^\s*image:\s*(openruntimes\/executor:[^\s]+)\s*$/m', $compose, $matches); - - $this->assertSame(1, $matched, 'Failed to find the canonical openruntimes executor image.'); - - return $matches[1]; - } - - private function readRepoFile(string $path): string - { - $contents = file_get_contents($this->repoPath($path)); - - $this->assertIsString($contents, "Failed to read {$path}."); - - return $contents; - } - - private function repoPath(string $path): string - { - return dirname(__DIR__, 5) . $path; - } -} From 0c3871a68149c914ac4d04219b4048a52899ed79 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 13 Apr 2026 16:33:32 +1200 Subject: [PATCH 41/92] fix: pass response to Http::execute() in GraphQL resolver (#11876) Http::execute() now requires a Response parameter as of utopia-php/http 0.34.20. The GraphQL resolver was only passing route and request, causing all GraphQL queries to fail with "Internal server error". Co-authored-by: Claude Opus 4.6 (1M context) --- src/Appwrite/GraphQL/Resolvers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 689724d9f1..54a43b0515 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -266,7 +266,7 @@ class Resolvers try { $route = $utopia->match($request, fresh: true); - $utopia->execute($route, $request); + $utopia->execute($route, $request, $response); } catch (\Throwable $e) { if ($beforeReject) { $e = $beforeReject($e); From 78bbe7758003da20888d4495ef630513079ee6eb Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 09:37:01 +0530 Subject: [PATCH 42/92] fix: align project sdk spec generation --- .../Http/Project/Platforms/Web/Create.php | 2 +- .../Http/Project/Platforms/Web/Update.php | 2 +- src/Appwrite/SDK/Specification/Format.php | 67 +++++++++++++++++++ .../SDK/Specification/Format/OpenAPI3.php | 8 +-- .../SDK/Specification/Format/Swagger2.php | 8 +-- tests/unit/SDK/Specification/FormatTest.php | 67 +++++++++++++++++++ 6 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 tests/unit/SDK/Specification/FormatTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 6794901c47..2fca0ace6c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -64,7 +64,7 @@ class Create extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Deprecated: Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->param('type', '', new Text(256), 'Deprecated: Platform type. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('request') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php index 1e1f1b5ac1..62d209ea25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -56,7 +56,7 @@ class Update extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('response') ->inject('queueForEvents') diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 02fac12a7a..8bcabfa086 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -78,6 +78,27 @@ abstract class Format ], ]; + private const array REQUEST_PARAMETER_OVERRIDES = [ + [ + 'namespace' => 'project', + 'methods' => [ + 'createKey', + 'updateKey', + ], + 'parameter' => 'scopes', + 'nullable' => false, + ], + [ + 'namespace' => 'project', + 'methods' => [ + 'createWebPlatform', + 'updateWebPlatform', + ], + 'parameter' => 'hostname', + 'required' => true, + ], + ]; + protected array $enumBlacklist = []; public function __construct(Container $container, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) @@ -774,8 +795,54 @@ abstract class Format return $values; } + protected function getRequestParameterOverride(string $service, string $method, string $param): array + { + foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + if ( + $override['namespace'] === $service + && \in_array($method, $override['methods'], true) + && $override['parameter'] === $param + ) { + return $override; + } + } + + return []; + } + + protected function isRequestParameterRequired(string $service, string $method, string $param, bool $required): bool + { + $override = $this->getRequestParameterOverride($service, $method, $param); + + return $override['required'] ?? $required; + } + + protected function isRequestParameterNullable(string $service, string $method, string $param, bool $nullable): bool + { + $override = $this->getRequestParameterOverride($service, $method, $param); + + return $override['nullable'] ?? $nullable; + } + + protected function shouldEmitRequestParameterDefault(string $service, string $method, string $param, bool $optional, mixed $default): bool + { + if (\is_null($default)) { + return false; + } + + if (!$optional) { + return false; + } + + return !$this->isRequestParameterRequired($service, $method, $param, false); + } + public function getResponseEnumName(string $model, string $param): ?string { + if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { + return 'PlatformType'; + } + if ($param !== 'status') { return null; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 7da48fc2ca..7426b1cc4a 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -384,7 +384,7 @@ class OpenAPI3 extends Format $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $this->isRequestParameterRequired($sdk->getNamespace() ?? '', $methodName, $name, !$param['optional']), ]; $isNullable = $validator instanceof Nullable; @@ -735,7 +735,7 @@ class OpenAPI3 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($this->shouldEmitRequestParameterDefault($sdk->getNamespace() ?? '', $methodName, $name, $param['optional'], $param['default'])) { // Param has default value $node['schema']['default'] = $param['default']; } @@ -746,7 +746,7 @@ class OpenAPI3 extends Format $node['in'] = 'query'; $temp['parameters'][] = $node; } else { // Param is in payload - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -783,7 +783,7 @@ class OpenAPI3 extends Format $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($this->isRequestParameterNullable($sdk->getNamespace() ?? '', $methodName, $name, $isNullable)) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index d95f99bb70..c0ab9d6766 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -386,7 +386,7 @@ class Swagger2 extends Format $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $this->isRequestParameterRequired($sdk->getNamespace() ?? '', $methodName, $name, !$param['optional']), ]; $isNullable = $validator instanceof Nullable; @@ -711,7 +711,7 @@ class Swagger2 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($this->shouldEmitRequestParameterDefault($sdk->getNamespace() ?? '', $methodName, $name, $param['optional'], $param['default'])) { // Param has default value $node['default'] = $param['default']; } @@ -729,7 +729,7 @@ class Swagger2 extends Format continue; } - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -755,7 +755,7 @@ class Swagger2 extends Format $body['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($this->isRequestParameterNullable($sdk->getNamespace() ?? '', $methodName, $name, $isNullable)) { $body['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php new file mode 100644 index 0000000000..051b2d7ca9 --- /dev/null +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -0,0 +1,67 @@ +format = new class (new Container(), [], [], [], [], 0, 'console') extends Format { + public function getName(): string + { + return 'test'; + } + + public function parse(): array + { + return []; + } + + public function requestParameterRequired(string $service, string $method, string $param, bool $required): bool + { + return $this->isRequestParameterRequired($service, $method, $param, $required); + } + + public function requestParameterNullable(string $service, string $method, string $param, bool $nullable): bool + { + return $this->isRequestParameterNullable($service, $method, $param, $nullable); + } + + public function requestParameterDefault(string $service, string $method, string $param, bool $optional, mixed $default): bool + { + return $this->shouldEmitRequestParameterDefault($service, $method, $param, $optional, $default); + } + }; + } + + public function testProjectRequestParameterOverrides(): void + { + $this->assertTrue($this->format->requestParameterRequired('project', 'createWebPlatform', 'hostname', false)); + $this->assertTrue($this->format->requestParameterRequired('project', 'updateWebPlatform', 'hostname', false)); + $this->assertFalse($this->format->requestParameterNullable('project', 'createKey', 'scopes', true)); + $this->assertFalse($this->format->requestParameterNullable('project', 'updateKey', 'scopes', true)); + $this->assertFalse($this->format->requestParameterDefault('project', 'createWebPlatform', 'hostname', true, '')); + $this->assertFalse($this->format->requestParameterDefault('project', 'updateWebPlatform', 'hostname', true, '')); + $this->assertTrue($this->format->requestParameterDefault('project', 'listPlatforms', 'queries', true, [])); + } + + public function testProjectPlatformResponseTypeUsesSharedEnumName(): void + { + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformAndroid', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWeb', 'type')); + } + + public function testExistingResponseEnumMappingsRemainUnchanged(): void + { + $this->assertSame('HealthCheckStatus', $this->format->getResponseEnumName('healthStatus', 'status')); + $this->assertNull($this->format->getResponseEnumName('key', 'name')); + } +} From 53c74582fc82f8b462359213bc105c8507ad037a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 09:42:17 +0530 Subject: [PATCH 43/92] refactor: simplify request parameter spec overrides --- src/Appwrite/SDK/Specification/Format.php | 49 +++++++------------ .../SDK/Specification/Format/OpenAPI3.php | 19 +++++-- .../SDK/Specification/Format/Swagger2.php | 19 +++++-- tests/unit/SDK/Specification/FormatTest.php | 34 ++++++------- 4 files changed, 61 insertions(+), 60 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 8bcabfa086..39e8c2e3c1 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -795,46 +795,33 @@ abstract class Format return $values; } - protected function getRequestParameterOverride(string $service, string $method, string $param): array + protected function getRequestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array { + $config = [ + 'required' => !$optional, + 'nullable' => $nullable, + 'emitDefault' => $optional && !\is_null($default), + ]; + foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { if ( - $override['namespace'] === $service - && \in_array($method, $override['methods'], true) - && $override['parameter'] === $param + $override['namespace'] !== $service + || !\in_array($method, $override['methods'], true) + || $override['parameter'] !== $param ) { - return $override; + continue; } + + $config['required'] = $override['required'] ?? $config['required']; + $config['nullable'] = $override['nullable'] ?? $config['nullable']; + break; } - return []; - } - - protected function isRequestParameterRequired(string $service, string $method, string $param, bool $required): bool - { - $override = $this->getRequestParameterOverride($service, $method, $param); - - return $override['required'] ?? $required; - } - - protected function isRequestParameterNullable(string $service, string $method, string $param, bool $nullable): bool - { - $override = $this->getRequestParameterOverride($service, $method, $param); - - return $override['nullable'] ?? $nullable; - } - - protected function shouldEmitRequestParameterDefault(string $service, string $method, string $param, bool $optional, mixed $default): bool - { - if (\is_null($default)) { - return false; + if ($config['required']) { + $config['emitDefault'] = false; } - if (!$optional) { - return false; - } - - return !$this->isRequestParameterRequired($service, $method, $param, false); + return $config; } public function getResponseEnumName(string $model, string $param): ?string diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 7426b1cc4a..b611558826 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -381,14 +381,23 @@ class OpenAPI3 extends Format */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => $this->isRequestParameterRequired($sdk->getNamespace() ?? '', $methodName, $name, !$param['optional']), + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -735,7 +744,7 @@ class OpenAPI3 extends Format break; } - if ($this->shouldEmitRequestParameterDefault($sdk->getNamespace() ?? '', $methodName, $name, $param['optional'], $param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['schema']['default'] = $param['default']; } @@ -783,7 +792,7 @@ class OpenAPI3 extends Format $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; } - if ($this->isRequestParameterNullable($sdk->getNamespace() ?? '', $methodName, $name, $isNullable)) { + if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index c0ab9d6766..413239f000 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -383,14 +383,23 @@ class Swagger2 extends Format /** @var Validator $validator */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => $this->isRequestParameterRequired($sdk->getNamespace() ?? '', $methodName, $name, !$param['optional']), + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -711,7 +720,7 @@ class Swagger2 extends Format break; } - if ($this->shouldEmitRequestParameterDefault($sdk->getNamespace() ?? '', $methodName, $name, $param['optional'], $param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['default'] = $param['default']; } @@ -755,7 +764,7 @@ class Swagger2 extends Format $body['schema']['properties'][$name]['x-global'] = true; } - if ($this->isRequestParameterNullable($sdk->getNamespace() ?? '', $methodName, $name, $isNullable)) { + if ($parameter['nullable']) { $body['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index 051b2d7ca9..70e3f907ac 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -25,32 +25,28 @@ class FormatTest extends TestCase return []; } - public function requestParameterRequired(string $service, string $method, string $param, bool $required): bool + public function requestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array { - return $this->isRequestParameterRequired($service, $method, $param, $required); - } - - public function requestParameterNullable(string $service, string $method, string $param, bool $nullable): bool - { - return $this->isRequestParameterNullable($service, $method, $param, $nullable); - } - - public function requestParameterDefault(string $service, string $method, string $param, bool $optional, mixed $default): bool - { - return $this->shouldEmitRequestParameterDefault($service, $method, $param, $optional, $default); + return $this->getRequestParameterConfig($service, $method, $param, $optional, $nullable, $default); } }; } public function testProjectRequestParameterOverrides(): void { - $this->assertTrue($this->format->requestParameterRequired('project', 'createWebPlatform', 'hostname', false)); - $this->assertTrue($this->format->requestParameterRequired('project', 'updateWebPlatform', 'hostname', false)); - $this->assertFalse($this->format->requestParameterNullable('project', 'createKey', 'scopes', true)); - $this->assertFalse($this->format->requestParameterNullable('project', 'updateKey', 'scopes', true)); - $this->assertFalse($this->format->requestParameterDefault('project', 'createWebPlatform', 'hostname', true, '')); - $this->assertFalse($this->format->requestParameterDefault('project', 'updateWebPlatform', 'hostname', true, '')); - $this->assertTrue($this->format->requestParameterDefault('project', 'listPlatforms', 'queries', true, [])); + $createWebPlatform = $this->format->requestParameterConfig('project', 'createWebPlatform', 'hostname', true, false, ''); + $updateWebPlatform = $this->format->requestParameterConfig('project', 'updateWebPlatform', 'hostname', true, false, ''); + $createKey = $this->format->requestParameterConfig('project', 'createKey', 'scopes', false, true, null); + $updateKey = $this->format->requestParameterConfig('project', 'updateKey', 'scopes', false, true, null); + $listPlatforms = $this->format->requestParameterConfig('project', 'listPlatforms', 'queries', true, false, []); + + $this->assertTrue($createWebPlatform['required']); + $this->assertFalse($createWebPlatform['emitDefault']); + $this->assertTrue($updateWebPlatform['required']); + $this->assertFalse($updateWebPlatform['emitDefault']); + $this->assertFalse($createKey['nullable']); + $this->assertFalse($updateKey['nullable']); + $this->assertTrue($listPlatforms['emitDefault']); } public function testProjectPlatformResponseTypeUsesSharedEnumName(): void From 815209ebb064d95244f9c2e6f26f0306d63ccbcb Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 10:09:33 +0530 Subject: [PATCH 44/92] fix: address sdk spec review feedback --- src/Appwrite/SDK/Specification/Format.php | 5 +-- tests/unit/SDK/Specification/FormatTest.php | 41 ++++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 39e8c2e3c1..39fbd2883e 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -800,7 +800,6 @@ abstract class Format $config = [ 'required' => !$optional, 'nullable' => $nullable, - 'emitDefault' => $optional && !\is_null($default), ]; foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { @@ -817,9 +816,7 @@ abstract class Format break; } - if ($config['required']) { - $config['emitDefault'] = false; - } + $config['emitDefault'] = !$config['required'] && !\is_null($default); return $config; } diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index 70e3f907ac..e21f2f3c43 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -6,30 +6,33 @@ use Appwrite\SDK\Specification\Format; use PHPUnit\Framework\TestCase; use Utopia\DI\Container; +class TestFormat extends Format +{ + public function getName(): string + { + return 'test'; + } + + public function parse(): array + { + return []; + } + + public function requestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array + { + return $this->getRequestParameterConfig($service, $method, $param, $optional, $nullable, $default); + } +} + class FormatTest extends TestCase { - private Format $format; + private TestFormat $format; protected function setUp(): void { parent::setUp(); - $this->format = new class (new Container(), [], [], [], [], 0, 'console') extends Format { - public function getName(): string - { - return 'test'; - } - - public function parse(): array - { - return []; - } - - public function requestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array - { - return $this->getRequestParameterConfig($service, $method, $param, $optional, $nullable, $default); - } - }; + $this->format = new TestFormat(new Container(), [], [], [], [], 0, 'console'); } public function testProjectRequestParameterOverrides(): void @@ -53,6 +56,10 @@ class FormatTest extends TestCase { $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformAndroid', 'type')); $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWeb', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformApple', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWindows', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformLinux', 'type')); + $this->assertNull($this->format->getResponseEnumName('platformList', 'type')); } public function testExistingResponseEnumMappingsRemainUnchanged(): void From 723cb1a48813b0a6947a5a2493d66d094fc502d7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 13:34:09 +0530 Subject: [PATCH 45/92] fix: require scopes for project keys --- .../Modules/Project/Http/Project/Keys/Create.php | 8 ++++---- .../Modules/Project/Http/Project/Keys/Update.php | 8 ++++---- src/Appwrite/SDK/Specification/Format.php | 9 --------- tests/unit/SDK/Specification/FormatTest.php | 4 ---- 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 59d2c1db49..e50f6b27f9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -62,7 +62,7 @@ class Create extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -73,12 +73,12 @@ class Create extends Base } /** - * @param array|null $scopes + * @param array $scopes */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -95,7 +95,7 @@ class Create extends Base 'resourceId' => $project->getId(), 'resourceType' => 'projects', 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 8759faacc1..c9c1889573 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -60,7 +60,7 @@ class Update extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -71,12 +71,12 @@ class Update extends Base } /** - * @param array|null $scopes + * @param array $scopes */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -92,7 +92,7 @@ class Update extends Base $updates = new Document([ 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, ]); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 39fbd2883e..91b090a9f6 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -79,15 +79,6 @@ abstract class Format ]; private const array REQUEST_PARAMETER_OVERRIDES = [ - [ - 'namespace' => 'project', - 'methods' => [ - 'createKey', - 'updateKey', - ], - 'parameter' => 'scopes', - 'nullable' => false, - ], [ 'namespace' => 'project', 'methods' => [ diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index e21f2f3c43..f99b29bfe2 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -39,16 +39,12 @@ class FormatTest extends TestCase { $createWebPlatform = $this->format->requestParameterConfig('project', 'createWebPlatform', 'hostname', true, false, ''); $updateWebPlatform = $this->format->requestParameterConfig('project', 'updateWebPlatform', 'hostname', true, false, ''); - $createKey = $this->format->requestParameterConfig('project', 'createKey', 'scopes', false, true, null); - $updateKey = $this->format->requestParameterConfig('project', 'updateKey', 'scopes', false, true, null); $listPlatforms = $this->format->requestParameterConfig('project', 'listPlatforms', 'queries', true, false, []); $this->assertTrue($createWebPlatform['required']); $this->assertFalse($createWebPlatform['emitDefault']); $this->assertTrue($updateWebPlatform['required']); $this->assertFalse($updateWebPlatform['emitDefault']); - $this->assertFalse($createKey['nullable']); - $this->assertFalse($updateKey['nullable']); $this->assertTrue($listPlatforms['emitDefault']); } From 035f6244e103a0d958b0180d5acc39ddd9ec4dde Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 13:40:14 +0530 Subject: [PATCH 46/92] Revert "fix: require scopes for project keys" This reverts commit 8deafcaf4d52a59cc2e1b27c7a128e8b7843afa4. --- .../Modules/Project/Http/Project/Keys/Create.php | 8 ++++---- .../Modules/Project/Http/Project/Keys/Update.php | 8 ++++---- src/Appwrite/SDK/Specification/Format.php | 9 +++++++++ tests/unit/SDK/Specification/FormatTest.php | 4 ++++ 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index e50f6b27f9..59d2c1db49 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -62,7 +62,7 @@ class Create extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -73,12 +73,12 @@ class Create extends Base } /** - * @param array $scopes + * @param array|null $scopes */ public function action( string $keyId, string $name, - array $scopes, + ?array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -95,7 +95,7 @@ class Create extends Base 'resourceId' => $project->getId(), 'resourceType' => 'projects', 'name' => $name, - 'scopes' => $scopes, + 'scopes' => $scopes ?? [], 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index c9c1889573..8759faacc1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -60,7 +60,7 @@ class Update extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -71,12 +71,12 @@ class Update extends Base } /** - * @param array $scopes + * @param array|null $scopes */ public function action( string $keyId, string $name, - array $scopes, + ?array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -92,7 +92,7 @@ class Update extends Base $updates = new Document([ 'name' => $name, - 'scopes' => $scopes, + 'scopes' => $scopes ?? [], 'expire' => $expire, ]); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 91b090a9f6..39fbd2883e 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -79,6 +79,15 @@ abstract class Format ]; private const array REQUEST_PARAMETER_OVERRIDES = [ + [ + 'namespace' => 'project', + 'methods' => [ + 'createKey', + 'updateKey', + ], + 'parameter' => 'scopes', + 'nullable' => false, + ], [ 'namespace' => 'project', 'methods' => [ diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index f99b29bfe2..e21f2f3c43 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -39,12 +39,16 @@ class FormatTest extends TestCase { $createWebPlatform = $this->format->requestParameterConfig('project', 'createWebPlatform', 'hostname', true, false, ''); $updateWebPlatform = $this->format->requestParameterConfig('project', 'updateWebPlatform', 'hostname', true, false, ''); + $createKey = $this->format->requestParameterConfig('project', 'createKey', 'scopes', false, true, null); + $updateKey = $this->format->requestParameterConfig('project', 'updateKey', 'scopes', false, true, null); $listPlatforms = $this->format->requestParameterConfig('project', 'listPlatforms', 'queries', true, false, []); $this->assertTrue($createWebPlatform['required']); $this->assertFalse($createWebPlatform['emitDefault']); $this->assertTrue($updateWebPlatform['required']); $this->assertFalse($updateWebPlatform['emitDefault']); + $this->assertFalse($createKey['nullable']); + $this->assertFalse($updateKey['nullable']); $this->assertTrue($listPlatforms['emitDefault']); } From a6af6093171fdbab104fa0e899e41a19a52c45fe Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 10:17:32 +0530 Subject: [PATCH 47/92] Remove scopes spec override, now fixed at source in #11839 --- src/Appwrite/SDK/Specification/Format.php | 9 --------- tests/unit/SDK/Specification/FormatTest.php | 4 ---- 2 files changed, 13 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 39fbd2883e..91b090a9f6 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -79,15 +79,6 @@ abstract class Format ]; private const array REQUEST_PARAMETER_OVERRIDES = [ - [ - 'namespace' => 'project', - 'methods' => [ - 'createKey', - 'updateKey', - ], - 'parameter' => 'scopes', - 'nullable' => false, - ], [ 'namespace' => 'project', 'methods' => [ diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index e21f2f3c43..f99b29bfe2 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -39,16 +39,12 @@ class FormatTest extends TestCase { $createWebPlatform = $this->format->requestParameterConfig('project', 'createWebPlatform', 'hostname', true, false, ''); $updateWebPlatform = $this->format->requestParameterConfig('project', 'updateWebPlatform', 'hostname', true, false, ''); - $createKey = $this->format->requestParameterConfig('project', 'createKey', 'scopes', false, true, null); - $updateKey = $this->format->requestParameterConfig('project', 'updateKey', 'scopes', false, true, null); $listPlatforms = $this->format->requestParameterConfig('project', 'listPlatforms', 'queries', true, false, []); $this->assertTrue($createWebPlatform['required']); $this->assertFalse($createWebPlatform['emitDefault']); $this->assertTrue($updateWebPlatform['required']); $this->assertFalse($updateWebPlatform['emitDefault']); - $this->assertFalse($createKey['nullable']); - $this->assertFalse($updateKey['nullable']); $this->assertTrue($listPlatforms['emitDefault']); } From 5b805d686b26a5032c9843db8de3186142df13a8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 13 Apr 2026 05:32:04 +0000 Subject: [PATCH 48/92] fix: reset response sent state between batched GraphQL queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utopia-php/http 0.34.20 added a guard that skips the action if $response->isSent() is true. In batched GraphQL requests the resolver reuses a single Response across all queries — after the first query's action calls send(), subsequent queries hit the guard, their actions are skipped, and stale/null payloads are returned. Add Response::clearSent() to the Appwrite Response subclass (which can access the protected $sent property from the parent) and call it in Resolvers::resolve() before each execute(). This ensures each batched query gets a fresh sent state while keeping the guard active for normal request paths. Also bumps utopia-php/http from 0.34.19 to 0.34.20 so CE CI tests against the same version used by downstream consumers (cloud). Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.lock | 64 +++++++++++++++--------------- src/Appwrite/GraphQL/Resolvers.php | 1 + src/Appwrite/Utopia/Response.php | 11 +++++ 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/composer.lock b/composer.lock index 3d7800a919..048ccdf6ea 100644 --- a/composer.lock +++ b/composer.lock @@ -2887,16 +2887,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" }, "funding": [ { @@ -2968,20 +2968,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "5d2ed36f7734637dacc025f179698031951b1692" + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", - "reference": "5d2ed36f7734637dacc025f179698031951b1692", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", "shasum": "" }, "require": { @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0" }, "funding": [ { @@ -3048,20 +3048,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0" }, "funding": [ { @@ -3128,7 +3128,7 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php85", @@ -4271,16 +4271,16 @@ }, { "name": "utopia-php/http", - "version": "0.34.19", + "version": "0.34.20", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c" + "reference": "d6b360d555022d16c16d40be51f86180364819f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/995c119f31866cacd42d63b1f922bf86eabb396c", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d6b360d555022d16c16d40be51f86180364819f8", + "reference": "d6b360d555022d16c16d40be51f86180364819f8", "shasum": "" }, "require": { @@ -4319,9 +4319,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.19" + "source": "https://github.com/utopia-php/http/tree/0.34.20" }, - "time": "2026-04-08T10:23:17+00:00" + "time": "2026-04-12T14:25:22+00:00" }, { "name": "utopia-php/image", @@ -7764,16 +7764,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -7823,7 +7823,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0" }, "funding": [ { @@ -7843,7 +7843,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -8014,7 +8014,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8070,7 +8070,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0" }, "funding": [ { diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 54a43b0515..65f8a64d68 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -262,6 +262,7 @@ class Resolvers $request = clone $request; $utopia->setResource('request', static fn () => $request); $response->setContentType(Response::CONTENT_TYPE_NULL); + $response->clearSent(); try { $route = $utopia->match($request, fresh: true); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 295348c665..5cd0e8366a 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -626,6 +626,17 @@ class Response extends SwooleResponse return $this->payload; } + /** + * Reset the sent flag so the response can be reused for another + * action execution (e.g. batched GraphQL queries that share one + * Response instance). + */ + public function clearSent(): static + { + $this->sent = false; + return $this; + } + /** * Function to add a response filter, the order of filters are first in - first out. * From 18b1c344e662eea02decaf4dfee45c9a2e34e8c8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 13 Apr 2026 06:34:58 +0000 Subject: [PATCH 49/92] 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 50/92] 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 51/92] 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 52/92] 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 53/92] 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 54/92] 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 55/92] 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 3263133e5f7332e67a1641bba38e52e520270dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:50:06 +0200 Subject: [PATCH 56/92] Implement secure webhook interfaces --- .../Platform/Modules/Webhooks/Http/Webhooks/Create.php | 5 ++++- .../Webhooks/Http/Webhooks/Signature/Update.php | 10 +++++++--- .../Platform/Modules/Webhooks/Services/Http.php | 4 ++-- src/Appwrite/Utopia/Response/Model/Webhook.php | 8 +++++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index e60810417c..e192dff2a0 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -21,6 +21,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Multiple; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -68,6 +69,7 @@ class Create extends Action ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -88,6 +90,7 @@ class Create extends Action bool $tls, string $authUsername, string $authPassword, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -107,7 +110,7 @@ class Create extends Action 'security' => $tls, 'httpUser' => $authUsername, 'httpPass' => $authPassword, - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php index e36fa5c2ce..fbff94735e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -1,6 +1,6 @@ param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -62,6 +65,7 @@ class Update extends Action public function action( string $webhookId, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -78,7 +82,7 @@ class Update extends Action } $updates = new Document([ - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), ]); $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php index 4805de6ebc..0e9c39a762 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook; -use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret\Update as UpdateWebhookSecret; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks; use Utopia\Platform\Service; @@ -26,6 +26,6 @@ class Http extends Service $this->addAction(GetWebhook::getName(), new GetWebhook()); $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); - $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); + $this->addAction(UpdateWebhookSecret::getName(), new UpdateWebhookSecret()); } } diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index c2a2c183b1..25c2c21d4a 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -69,12 +69,16 @@ class Webhook extends Model 'default' => '', 'example' => 'password', ]) + /* + Not exposed for security; Secret is currently write-only. + ->addRule('secret', [ 'type' => self::TYPE_STRING, 'description' => 'Signature key which can be used to validate incoming webhook payloads.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) + */ ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Indicates if this webhook is enabled.', @@ -106,7 +110,9 @@ class Webhook extends Model $document->setAttribute('authPassword', $document->getAttribute('httpPass')); $document->removeAttribute('httpPass'); - $document->setAttribute('secret', $document->getAttribute('signatureKey')); + // Would be 'secret', but we want it to be write-only. + // $document->setAttribute('secret', $document->getAttribute('signatureKey')); + // Remove DB-level attribute, just to be sure. $document->removeAttribute('signatureKey'); return $document; From d40a355d9de308d6569637e41341d2bd94d7194a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 18:21:39 +0530 Subject: [PATCH 57/92] 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 a941d8b8557fd5bc1339ba0cc4b2d1f66d996c12 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:42:38 +0100 Subject: [PATCH 58/92] fix: trim execution queue payload to project ID only The v1-executions queue was serialising the full project document (OAuth providers, webhooks, keys, auths config, permissions, etc.) into every message. The worker DI system already re-fetches the complete project from the platform database using only the project ID, so the full document was wasted bytes. Override trimPayload() in the Execution event class to include only $id in the project stub, reducing message size significantly. Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Event/Execution.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Appwrite/Event/Execution.php b/src/Appwrite/Event/Execution.php index 398025565c..9e735991ba 100644 --- a/src/Appwrite/Event/Execution.php +++ b/src/Appwrite/Event/Execution.php @@ -53,4 +53,23 @@ class Execution extends Event 'execution' => $this->execution, ]; } + + /** + * Trim payload for the execution event. + * Only the project ID is needed — the worker DI fetches the full project from the platform database. + * + * @return array + */ + protected function trimPayload(): array + { + $trimmed = []; + + if ($this->project) { + $trimmed['project'] = new Document([ + '$id' => $this->project->getId(), + ]); + } + + return $trimmed; + } } From 28d285d5c5025416448beb3f27453f577073789c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:53:06 +0200 Subject: [PATCH 59/92] Improved tests for webhook edge cases --- tests/e2e/Services/Webhooks/WebhooksBase.php | 359 ++++++++++++++++++- 1 file changed, 356 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 82181aa858..fc75a3ede9 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -708,6 +708,351 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } + public function testSecretRotationZeroDowntime(): void + { + // Create webhook pointing to request-catcher so deliveries are captured + $webhook = $this->createWebhook( + ID::unique(), + 'Rotation Test Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $originalSecret = $webhook['body']['secret']; + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); + + // Step 1: Trigger user creation with the original auto-generated secret + $email1 = uniqid() . 'rotation1@localhost.test'; + $user1 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email1, + 'password' => 'password', + 'name' => 'Rotation User 1', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + $userId1 = $user1['body']['$id']; + + // Verify webhook delivery is signed with the original secret + $this->assertEventually(function () use ($userId1, $originalSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId1) { + $this->assertStringContainsString( + "users.{$userId1}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $originalSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Step 2: Rotate the secret to a known custom value + $newSecret = 'new-key-after-rotation'; + $updated = $this->updateWebhookSecret($webhookId, $newSecret); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($newSecret, $updated['body']['secret']); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); + + // Step 3: Trigger another user creation — should be signed with the new secret + $email2 = uniqid() . 'rotation2@localhost.test'; + $user2 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email2, + 'password' => 'password', + 'name' => 'Rotation User 2', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + $userId2 = $user2['body']['$id']; + + // Verify webhook delivery is signed with the new rotated secret + $this->assertEventually(function () use ($userId2, $newSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId2) { + $this->assertStringContainsString( + "users.{$userId2}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $newSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookWithCustomSecret(): void + { + $customSecret = 'custom-secret-key'; + + // Create webhook with a custom secret pointing to request-catcher + $webhook = $this->createWebhook( + ID::unique(), + 'Custom Secret Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null, + $customSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals($customSecret, $webhook['body']['secret']); + + // Trigger user creation to generate a webhook delivery + $email = uniqid() . 'customsecret@localhost.test'; + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Custom Secret User', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Verify webhook delivery is signed with the custom secret + $this->assertEventually(function () use ($userId, $customSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId) { + $this->assertStringContainsString( + "users.{$userId}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $customSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookSecretMinLength(): void + { + // 7 chars — below minimum of 8 + $webhook = $this->createWebhook( + ID::unique(), + 'Short Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'short12' + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + + // 8 chars — exactly at minimum + $webhook = $this->createWebhook( + ID::unique(), + 'Min Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'exact8ch' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals('exact8ch', $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testCreateWebhookSecretMaxLength(): void + { + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $webhook = $this->createWebhook( + ID::unique(), + 'Max Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $maxSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals($maxSecret, $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $webhook = $this->createWebhook( + ID::unique(), + 'Too Long Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $tooLongSecret + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookSecretMinLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Min Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 7 chars — below minimum of 8 + $updated = $this->updateWebhookSecret($webhookId, 'short12'); + $this->assertEquals(400, $updated['headers']['status-code']); + + // 8 chars — exactly at minimum + $updated = $this->updateWebhookSecret($webhookId, 'exact8ch'); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('exact8ch', $updated['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretMaxLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Max Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $updated = $this->updateWebhookSecret($webhookId, $maxSecret); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($maxSecret, $updated['body']['secret']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $updated = $this->updateWebhookSecret($webhookId, $tooLongSecret); + $this->assertEquals(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testWebhookSecretNotExposedInResponses(): void + { + // Create webhook — secret must not leak in creation response + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Exposure Test', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'my-custom-secret' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertArrayNotHasKey('secret', $webhook['body']); + $this->assertArrayNotHasKey('signatureKey', $webhook['body']); + + // Get webhook — secret must not leak + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $get['body']); + $this->assertArrayNotHasKey('signatureKey', $get['body']); + + // List webhooks — secret must not leak + $list = $this->listWebhooks(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + foreach ($list['body']['webhooks'] as $item) { + $this->assertArrayNotHasKey('secret', $item); + $this->assertArrayNotHasKey('signatureKey', $item); + } + + // Update webhook — secret must not leak + $updated = $this->updateWebhook( + $webhookId, + 'Secret Exposure Test Updated', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertArrayNotHasKey('signatureKey', $updated['body']); + + // Update webhook secret — secret must not leak + $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); + $this->assertEquals(200, $rotated['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $rotated['body']); + $this->assertArrayNotHasKey('signatureKey', $rotated['body']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // URL validation tests public function testCreateWebhookWithPrivateDomain(): void @@ -1779,7 +2124,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword, ?string $secret = null): mixed { $params = [ 'webhookId' => $webhookId, @@ -1800,6 +2145,9 @@ trait WebhooksBase if ($authPassword !== null) { $params['authPassword'] = $authPassword; } + if ($secret !== null) { + $params['secret'] = $secret; + } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', @@ -1838,12 +2186,17 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSecret(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId, ?string $secret = null): mixed { + $params = []; + if ($secret !== null) { + $params['secret'] = $secret; + } + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ], $this->getHeaders()), $params); return $webhook; } From 9f1ec356d13fe0d52425c7ff74ed82d5d5cfc926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:53:25 +0200 Subject: [PATCH 60/92] Formatting fix --- src/Appwrite/Utopia/Response/Model/Webhook.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 25c2c21d4a..70e8ef9842 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -71,7 +71,7 @@ class Webhook extends Model ]) /* Not exposed for security; Secret is currently write-only. - + ->addRule('secret', [ 'type' => self::TYPE_STRING, 'description' => 'Signature key which can be used to validate incoming webhook payloads.', From c9fceb870c707cd5442dffe2d2424a89094015a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:57:24 +0200 Subject: [PATCH 61/92] Fix folder structure --- .../Webhooks/Http/Webhooks/{Signature => Secret}/Update.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/{Signature => Secret}/Update.php (100%) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php similarity index 100% rename from src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php rename to src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php From a1342b4b9d3bc5e6bd80b4a352a457804da4e1a3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 18:32:38 +0530 Subject: [PATCH 62/92] 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 2585518e334041743256bd3a8a10388418900c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 15:08:13 +0200 Subject: [PATCH 63/92] Fix bugs --- .../Platform/Modules/Webhooks/Http/Webhooks/Get.php | 2 ++ .../Platform/Modules/Webhooks/Http/Webhooks/Update.php | 2 ++ .../Platform/Modules/Webhooks/Http/Webhooks/XList.php | 4 ++++ src/Appwrite/Utopia/Response/Model/Webhook.php | 10 ++-------- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index ebe6fa7bcb..a42500ca46 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -72,6 +72,8 @@ class Get extends Action throw new Exception(Exception::WEBHOOK_NOT_FOUND); } + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index 6085eb74b2..e7b516449e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -118,6 +118,8 @@ class Update extends Action $queueForEvents->setParam('webhookId', $webhook->getId()); + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 763c0d339b..f0961b541c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -120,6 +120,10 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } + foreach ($webhooks as $webhook) { + $webhook->removeAttribute('signatureKey'); + } + $response->dynamic(new Document([ 'webhooks' => $webhooks, 'total' => $total, diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 70e8ef9842..6a0197e4a1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -69,16 +69,12 @@ class Webhook extends Model 'default' => '', 'example' => 'password', ]) - /* - Not exposed for security; Secret is currently write-only. - ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validate incoming webhook payloads.', + 'description' => 'Signature key which can be used to validate incoming webhook payloads. Only returned on creation and secret rotation.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) - */ ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Indicates if this webhook is enabled.', @@ -110,9 +106,7 @@ class Webhook extends Model $document->setAttribute('authPassword', $document->getAttribute('httpPass')); $document->removeAttribute('httpPass'); - // Would be 'secret', but we want it to be write-only. - // $document->setAttribute('secret', $document->getAttribute('signatureKey')); - // Remove DB-level attribute, just to be sure. + $document->setAttribute('secret', $document->getAttribute('signatureKey')); $document->removeAttribute('signatureKey'); return $document; From db406b0a275bbafa13bd5aee2d66541ff6f2de8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 15:08:20 +0200 Subject: [PATCH 64/92] Fix tests --- tests/e2e/Services/Webhooks/WebhooksBase.php | 48 ++++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index fc75a3ede9..f926a97c81 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -694,10 +694,10 @@ trait WebhooksBase $this->assertEquals(128, \strlen($updated['body']['secret'])); $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new secret persisted via GET + // Verify secret is not exposed via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSecret, $get['body']['secret']); + $this->assertEmpty($get['body']['secret']); // Test secret update on non-existent webhook $notFound = $this->updateWebhookSecret('non-existent-id'); @@ -996,7 +996,7 @@ trait WebhooksBase public function testWebhookSecretNotExposedInResponses(): void { - // Create webhook — secret must not leak in creation response + // Create webhook — secret IS returned on creation $webhook = $this->createWebhook( ID::unique(), 'Secret Exposure Test', @@ -1011,24 +1011,24 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $webhookId = $webhook['body']['$id']; - $this->assertArrayNotHasKey('secret', $webhook['body']); + $this->assertEquals('my-custom-secret', $webhook['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $webhook['body']); - // Get webhook — secret must not leak + // Get webhook — secret must not be exposed $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $get['body']); + $this->assertEmpty($get['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $get['body']); - // List webhooks — secret must not leak + // List webhooks — secret must not be exposed $list = $this->listWebhooks(null, true); $this->assertEquals(200, $list['headers']['status-code']); foreach ($list['body']['webhooks'] as $item) { - $this->assertArrayNotHasKey('secret', $item); + $this->assertEmpty($item['secret']); $this->assertArrayNotHasKey('signatureKey', $item); } - // Update webhook — secret must not leak + // Update webhook — secret must not be exposed $updated = $this->updateWebhook( $webhookId, 'Secret Exposure Test Updated', @@ -1040,13 +1040,13 @@ trait WebhooksBase null ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertEmpty($updated['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $updated['body']); - // Update webhook secret — secret must not leak + // Update webhook secret — secret IS returned on rotation $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); $this->assertEquals(200, $rotated['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $rotated['body']); + $this->assertEquals('rotated-secret-key', $rotated['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $rotated['body']); // Cleanup @@ -1228,6 +1228,12 @@ trait WebhooksBase { $customId = 'my-custom-webhook-id'; + // Clean up stale webhook from a previous run if it exists + $existing = $this->getWebhook($customId); + if ($existing['headers']['status-code'] === 200) { + $this->deleteWebhook($customId); + } + $webhook = $this->createWebhook( $customId, 'Custom ID Webhook', @@ -1247,6 +1253,19 @@ trait WebhooksBase $this->assertEquals(200, $get['headers']['status-code']); $this->assertEquals($customId, $get['body']['$id']); + // Ensure duplicate creation fails + $duplicate = $this->createWebhook( + $customId, + 'Duplicate Custom ID Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(409, $duplicate['headers']['status-code']); + // Cleanup $this->deleteWebhook($customId); } @@ -1282,8 +1301,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['tls']); $this->assertEquals('myuser', $get['body']['authUsername']); $this->assertEquals('mypass', $get['body']['authPassword']); - $this->assertNotEmpty($get['body']['secret']); - $this->assertEquals(128, \strlen($get['body']['secret'])); + $this->assertEmpty($get['body']['secret']); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1990,7 +2008,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['security']); $this->assertEquals('getuser', $get['body']['httpUser']); $this->assertEquals('getpass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); + $this->assertEmpty($get['body']['signatureKey']); // Cleanup $this->deleteWebhook($webhookId); From d52d6c0bf0a1c4964699c4b19cbd3b9d910164bc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 14 Apr 2026 01:13:32 +1200 Subject: [PATCH 65/92] (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 66/92] (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 67/92] 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 68/92] 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 69/92] 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 70/92] 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 71/92] 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 72/92] 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 73/92] 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 74/92] 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 75/92] 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 76/92] 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 77/92] 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 78/92] =?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 79/92] 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 80/92] 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 81/92] 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 82/92] 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 83/92] 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 84/92] 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 85/92] 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 86/92] 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 87/92] 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 88/92] 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 89/92] 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 90/92] 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 91/92] 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 92/92] 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); } }