From 130c2221ecfa3b183c4a51acd38fbff0dc52d23f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 22:12:21 +0530 Subject: [PATCH 01/13] Fix VectorsDB metadata bootstrap race --- .../Http/VectorsDB/Collections/Create.php | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index a7e2d68eac..787c7ae0d9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -62,7 +62,7 @@ class Create extends CollectionAction new SDKResponse( code: SwooleResponse::STATUS_CODE_CREATED, model: $this->getResponseModel(), - ) + ), ], contentType: ContentType::JSON )) @@ -72,7 +72,7 @@ class Create extends CollectionAction ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimension.') ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('enabled', true, new Boolean, 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') @@ -95,7 +95,7 @@ class Create extends CollectionAction $permissions = Permission::aggregate($permissions) ?? []; try { - $collection = $dbForProject->createDocument('database_' . $database->getSequence(), new Document([ + $collection = $dbForProject->createDocument('database_'.$database->getSequence(), new Document([ '$id' => $collectionId, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -130,25 +130,27 @@ class Create extends CollectionAction $indexes[] = new Document($index); } try { - if (!$dbForDatabases->exists(null, Database::METADATA)) { - try { - $dbForDatabases->create(); - } catch (DuplicateException) { - } + // Bootstrap the database metadata without a separate existence + // check to avoid races when multiple first collections are created + // concurrently for the same VectorsDB database. + try { + $dbForDatabases->create(); + } catch (DuplicateException) { } $dbForDatabases->createCollection( - id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + id: 'database_'.$database->getSequence().'_collection_'.$collection->getSequence(), permissions: $permissions, documentSecurity: $documentSecurity, - attributes:$attributes, - indexes:$indexes + attributes: $attributes, + indexes: $indexes ); // Create attribute and indexes metadata documents in the attributes and indexes collections // needed for the get and list calls $attributeDocs = array_map(function ($attributeConfig) use ($database, $collection, $databaseId, $collectionId, $dimension) { $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; + return new Document([ - '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), 'key' => $key, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -173,7 +175,7 @@ class Create extends CollectionAction $key = \is_string($indexConfig['$id']) ? $indexConfig['$id'] : (string) $indexConfig['$id']; return new Document([ - '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), 'key' => $key, 'status' => 'available', 'databaseInternalId' => $database->getSequence(), @@ -187,7 +189,7 @@ class Create extends CollectionAction ]); }, $collections['defaultIndexes']); - if (!empty($indexDocs)) { + if (! empty($indexDocs)) { $dbForProject->createDocuments('indexes', $indexDocs); } } catch (DuplicateException) { From a5f45b46e9dfbff8ec640cc9dd3ba7e4b708ec88 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:41:44 +0530 Subject: [PATCH 02/13] Handle raced VectorsDB metadata bootstrap errors --- .../Http/VectorsDB/Collections/Create.php | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index 787c7ae0d9..d03213d4b8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -72,7 +72,7 @@ class Create extends CollectionAction ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimension.') ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean, 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') @@ -133,9 +133,23 @@ class Create extends CollectionAction // Bootstrap the database metadata without a separate existence // check to avoid races when multiple first collections are created // concurrently for the same VectorsDB database. - try { - $dbForDatabases->create(); - } catch (DuplicateException) { + for ($attempt = 0; $attempt < 5; $attempt++) { + try { + $dbForDatabases->create(); + break; + } catch (DuplicateException) { + break; + } catch (\Throwable $e) { + if ($dbForDatabases->exists(null, Database::METADATA)) { + break; + } + + if ($attempt === 4) { + throw $e; + } + + \usleep(100_000); + } } $dbForDatabases->createCollection( id: 'database_'.$database->getSequence().'_collection_'.$collection->getSequence(), From 3cb53f06047e3411893b45b49ef4671e3fe2ea85 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:43:51 +0530 Subject: [PATCH 03/13] Drop unrelated formatting churn from VectorsDB fix --- .../Http/VectorsDB/Collections/Create.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index d03213d4b8..0294790a9e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -62,7 +62,7 @@ class Create extends CollectionAction new SDKResponse( code: SwooleResponse::STATUS_CODE_CREATED, model: $this->getResponseModel(), - ), + ) ], contentType: ContentType::JSON )) @@ -95,7 +95,7 @@ class Create extends CollectionAction $permissions = Permission::aggregate($permissions) ?? []; try { - $collection = $dbForProject->createDocument('database_'.$database->getSequence(), new Document([ + $collection = $dbForProject->createDocument('database_' . $database->getSequence(), new Document([ '$id' => $collectionId, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -152,11 +152,11 @@ class Create extends CollectionAction } } $dbForDatabases->createCollection( - id: 'database_'.$database->getSequence().'_collection_'.$collection->getSequence(), + id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), permissions: $permissions, documentSecurity: $documentSecurity, - attributes: $attributes, - indexes: $indexes + attributes:$attributes, + indexes:$indexes ); // Create attribute and indexes metadata documents in the attributes and indexes collections // needed for the get and list calls @@ -164,7 +164,7 @@ class Create extends CollectionAction $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; return new Document([ - '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), 'key' => $key, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -189,7 +189,7 @@ class Create extends CollectionAction $key = \is_string($indexConfig['$id']) ? $indexConfig['$id'] : (string) $indexConfig['$id']; return new Document([ - '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), 'key' => $key, 'status' => 'available', 'databaseInternalId' => $database->getSequence(), @@ -203,7 +203,7 @@ class Create extends CollectionAction ]); }, $collections['defaultIndexes']); - if (! empty($indexDocs)) { + if (!empty($indexDocs)) { $dbForProject->createDocuments('indexes', $indexDocs); } } catch (DuplicateException) { From f3f2855fe5e74917cba658799ae3250fcecf4516 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:44:19 +0530 Subject: [PATCH 04/13] Remove final formatting-only diff --- .../Modules/Databases/Http/VectorsDB/Collections/Create.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index 0294790a9e..58433c7deb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -162,7 +162,6 @@ class Create extends CollectionAction // needed for the get and list calls $attributeDocs = array_map(function ($attributeConfig) use ($database, $collection, $databaseId, $collectionId, $dimension) { $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; - return new Document([ '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), 'key' => $key, From c978b6f34f7a18a740cdec915f2de0b895f1ab81 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:58:25 +0530 Subject: [PATCH 05/13] Stabilize function deployment activation in tests --- tests/e2e/Services/Functions/FunctionsBase.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index af426d5221..42976cda84 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -100,6 +100,24 @@ trait FunctionsBase 'x-appwrite-key' => $this->getProject()['apiKey'], ])); $this->assertNotEquals(401, $function['headers']['status-code'], 'Auth failed while polling function activation'); + + if ( + ($function['body']['deploymentId'] ?? '') !== $deploymentId + && ($function['body']['latestDeploymentId'] ?? '') === $deploymentId + && ($function['body']['latestDeploymentStatus'] ?? '') === 'ready' + ) { + $activation = $this->updateFunctionDeployment($functionId, $deploymentId); + $this->assertContains( + $activation['headers']['status-code'], + [200, 409], + 'Deployment activation request failed: ' . json_encode($activation['body'], JSON_PRETTY_PRINT) + ); + + if ($activation['headers']['status-code'] === 200) { + $function = $activation; + } + } + $this->assertEquals($deploymentId, $function['body']['deploymentId'] ?? '', 'Deployment is not activated, deployment: ' . json_encode($function['body'], JSON_PRETTY_PRINT)); }, 120000, 500); } From 66e68aea143eda00130c3b793960940b788eb4cd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 5 Apr 2026 19:37:29 +0530 Subject: [PATCH 06/13] fix: fail specs when docs are missing --- app/cli.php | 6 +++++- src/Appwrite/SDK/Specification/Format.php | 19 +++++++++++++++++++ .../SDK/Specification/Format/OpenAPI3.php | 4 ++-- .../SDK/Specification/Format/Swagger2.php | 4 ++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/cli.php b/app/cli.php index b8721320be..b636707f1c 100644 --- a/app/cli.php +++ b/app/cli.php @@ -329,17 +329,20 @@ $setResource('bus', function (Registry $register) use ($cli) { $setResource('telemetry', fn () => new NoTelemetry(), []); +$exitCode = 0; + $cli ->error() ->inject('error') ->inject('logError') - ->action(function (Throwable $error, callable $logError) use ($taskName) { + ->action(function (Throwable $error, callable $logError) use ($taskName, &$exitCode) { call_user_func_array($logError, [ $error, 'Task', $taskName, ]); + $exitCode = 1; Timer::clearAll(); }); @@ -348,3 +351,4 @@ $cli->shutdown()->action(fn () => Timer::clearAll()); Runtime::enableCoroutine(SWOOLE_HOOK_ALL); require_once __DIR__ . '/init/span.php'; run($cli->run(...)); +Console::exit($exitCode); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 7a867c5b91..dd4d378345 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -210,6 +210,25 @@ abstract class Format return $this->services; } + protected function getDescriptionContents(?string $description): string + { + if ($description === null || $description === '') { + return ''; + } + + if (!\str_ends_with($description, '.md')) { + return $description; + } + + $contents = @\file_get_contents($description); + + if ($contents === false) { + throw new \RuntimeException('Documentation file not found or unreadable: ' . $description); + } + + return $contents; + } + protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 753a0dc52f..41ed386e30 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -128,7 +128,7 @@ class OpenAPI3 extends Format if ($desc === null) { $desc = ''; } - $descContents = \str_ends_with($desc, '.md') ? \file_get_contents($desc) : $desc; + $descContents = $this->getDescriptionContents($desc); $temp = [ 'summary' => $route->getDesc(), @@ -193,7 +193,7 @@ class OpenAPI3 extends Format 'parameters' => [], 'required' => [], 'responses' => [], - 'description' => ($desc) ? \file_get_contents($desc) : '', + 'description' => $this->getDescriptionContents($desc), 'demo' => \strtolower($namespace) . '/' . Template::fromCamelCaseToDash($methodObj->getMethodName()) . '.md', 'public' => $methodObj->isPublic(), ]; diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 3e9ac891fa..dc65bea215 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -129,7 +129,7 @@ class Swagger2 extends Format if ($desc === null) { $desc = ''; } - $descContents = \str_ends_with($desc, '.md') ? \file_get_contents($desc) : $desc; + $descContents = $this->getDescriptionContents($desc); $temp = [ 'summary' => $route->getDesc(), @@ -201,7 +201,7 @@ class Swagger2 extends Format 'parameters' => [], 'required' => [], 'responses' => [], - 'description' => ($desc) ? \file_get_contents($desc) : '', + 'description' => $this->getDescriptionContents($desc), 'demo' => \strtolower($namespace) . '/' . Template::fromCamelCaseToDash($methodObj->getMethodName()) . '.md', 'public' => $methodObj->isPublic(), ]; From 9be447aacf8b66289a09430410d30e7eeeaf961d Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 5 Apr 2026 17:20:31 +0300 Subject: [PATCH 07/13] Update enqueue timer and improve schedule function logic Reduced the ENQUEUE_TIMER constant from 60 seconds to 30 seconds. Modified the condition for currentTick to use less than or equal to (<=) instead of less than (<) for better accuracy in scheduling. Changed return statement to continue in case of missing schedule key to enhance flow control. --- src/Appwrite/Platform/Tasks/ScheduleFunctions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 88725a190a..69f105652c 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -61,7 +61,7 @@ class ScheduleFunctions extends ScheduleBase $nextDate = $cron->getNextRunDate(); $next = DateTime::format($nextDate); - $currentTick = $next < $timeFrame; + $currentTick = $next <= $timeFrame; if (!$currentTick) { continue; @@ -88,7 +88,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]; From 5ab28ad99acaf946db36438cdbe126ba6ebf18f7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 5 Apr 2026 19:52:48 +0530 Subject: [PATCH 08/13] docs: add missing json migration references --- docs/references/migrations/migration-json-export.md | 1 + docs/references/migrations/migration-json-import.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/references/migrations/migration-json-export.md create mode 100644 docs/references/migrations/migration-json-import.md diff --git a/docs/references/migrations/migration-json-export.md b/docs/references/migrations/migration-json-export.md new file mode 100644 index 0000000000..8a955c5990 --- /dev/null +++ b/docs/references/migrations/migration-json-export.md @@ -0,0 +1 @@ +Export documents to a JSON file from your Appwrite database. This endpoint allows you to export documents to a JSON file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete. diff --git a/docs/references/migrations/migration-json-import.md b/docs/references/migrations/migration-json-import.md new file mode 100644 index 0000000000..2eeeaf5619 --- /dev/null +++ b/docs/references/migrations/migration-json-import.md @@ -0,0 +1 @@ +Import documents from a JSON file into your Appwrite database. This endpoint allows you to import documents from a JSON file uploaded to Appwrite Storage bucket. From 5d1da00138c87a7b9e6c082a5feb6413ce57ee92 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 5 Apr 2026 20:12:25 +0530 Subject: [PATCH 09/13] refactor: remove redundant desc guards --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 3 --- src/Appwrite/SDK/Specification/Format/Swagger2.php | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 41ed386e30..88f577eac6 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -125,9 +125,6 @@ class OpenAPI3 extends Format $namespace = $sdk->getNamespace() ?? 'default'; - if ($desc === null) { - $desc = ''; - } $descContents = $this->getDescriptionContents($desc); $temp = [ diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index dc65bea215..f9c79431f0 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -126,9 +126,6 @@ class Swagger2 extends Format $sdkPlatforms = array_values(array_unique($sdkPlatforms)); $namespace = $sdk->getNamespace() ?? 'default'; - if ($desc === null) { - $desc = ''; - } $descContents = $this->getDescriptionContents($desc); $temp = [ From 44f3bbae03115024ab3132d88c4ed86757aa64ac Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 6 Apr 2026 01:40:07 +0000 Subject: [PATCH 10/13] fix: add CORS headers to error responses The Http::error() handler was missing CORS headers, causing browsers to block error responses (e.g. 403 PROJECT_PAUSED) with a generic CORS error instead of showing the actual error message. This injects the cors resource into the error handler and adds CORS headers before sending the error response, matching the pattern already used in Http::init(). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/general.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 3bf5f027f2..3f8adeb368 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1196,7 +1196,8 @@ Http::error() ->inject('bus') ->inject('devKey') ->inject('authorization') - ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { + ->inject('cors') + ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization, Cors $cors) { $trace = $error->getTrace(); foreach (array_slice($trace, 0, 100) as $index => $traceEntry) { @@ -1493,6 +1494,10 @@ Http::error() 'type' => $type, ]; + foreach ($cors->headers($request->getOrigin()) as $name => $value) { + $response->addHeader($name, $value); + } + $response ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') ->addHeader('Expires', '0') From ba2584987136a5e4e430891e16563e313ff50d13 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 6 Apr 2026 02:59:08 +0000 Subject: [PATCH 11/13] fix: resolve cors safely in error handler to avoid cascading failures - Remove cors from inject chain; resolve via getResource() inside try-catch so DB failures don't cascade when resolving the cors resource dependency chain (cors -> allowedHostnames -> rule -> DB) - Use override:true on addHeader to prevent duplicate CORS headers when init() already set them before the exception was thrown - Degrades gracefully: if cors resolution fails, error response is sent without CORS headers (same behavior as before this PR) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/general.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 3f8adeb368..3eeeef3fae 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1196,8 +1196,7 @@ Http::error() ->inject('bus') ->inject('devKey') ->inject('authorization') - ->inject('cors') - ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization, Cors $cors) { + ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { $trace = $error->getTrace(); foreach (array_slice($trace, 0, 100) as $index => $traceEntry) { @@ -1494,8 +1493,17 @@ Http::error() 'type' => $type, ]; - foreach ($cors->headers($request->getOrigin()) as $name => $value) { - $response->addHeader($name, $value); + // Add CORS headers to error responses so browsers can read the error. + // Wrapped in try-catch: if the error itself is a DB failure, resolving + // the cors resource (which depends on rule -> DB) would cascade. + // Uses override:true to avoid duplicate headers if init() already set them. + try { + $cors = $utopia->getResource('cors'); + foreach ($cors->headers($request->getOrigin()) as $name => $value) { + $response->addHeader($name, $value, override: true); + } + } catch (Throwable) { + // Degrade gracefully - error response without CORS is no worse than before. } $response From cb74a5756a81d0b961756583ab348fbd2eccc894 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 10:20:18 +0530 Subject: [PATCH 12/13] Remove request and response static state --- app/controllers/general.php | 4 ++-- src/Appwrite/Utopia/Request.php | 18 ++++++++--------- src/Appwrite/Utopia/Response.php | 31 +++++++++++++++++++++++++++--- tests/unit/Utopia/RequestTest.php | 15 +++++++++++++++ tests/unit/Utopia/ResponseTest.php | 23 ++++++++++++++++++++++ 5 files changed, 77 insertions(+), 14 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 3bf5f027f2..dcc5764bdd 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -868,7 +868,7 @@ Http::init() * Request format */ $route = $utopia->getRoute(); - Request::setRoute($route); + $request->setRoute($route); if ($route === null) { $response->setStatusCode(404); @@ -1019,7 +1019,7 @@ Http::init() return; } $route = $request->getRoute(); - if ($route->getLabel('origin', false) === '*') { + if ($route?->getLabel('origin', false) === '*') { return; } if (!$originValidator->isValid($origin)) { diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 9428ff9d88..ed602ecdd5 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -17,7 +17,7 @@ class Request extends UtopiaRequest * @var array */ private array $filters = []; - private static ?Route $route = null; + private ?Route $route = null; public function __construct(SwooleRequest $request) { @@ -34,11 +34,11 @@ class Request extends UtopiaRequest { $parameters = parent::getParams(); - if (!$this->hasFilters() || !self::hasRoute()) { + if (!$this->hasFilters() || !$this->hasRoute()) { return $parameters; } - $methods = self::getRoute()->getLabel('sdk', null); + $methods = $this->getRoute()?->getLabel('sdk', null); if (empty($methods)) { return $parameters; @@ -131,9 +131,9 @@ class Request extends UtopiaRequest * * @return void */ - public static function setRoute(?Route $route): void + public function setRoute(?Route $route): void { - self::$route = $route; + $this->route = $route; } /** @@ -141,9 +141,9 @@ class Request extends UtopiaRequest * * @return Route|null */ - public static function getRoute(): ?Route + public function getRoute(): ?Route { - return self::$route; + return $this->route; } /** @@ -151,9 +151,9 @@ class Request extends UtopiaRequest * * @return bool */ - public static function hasRoute(): bool + public function hasRoute(): bool { - return self::$route !== null; + return $this->route !== null; } /** diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index e01dc58bf6..649b0562a5 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -8,6 +8,7 @@ use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Model; use Exception; use JsonException; +use Swoole\Coroutine; use Swoole\Http\Response as SwooleHTTPResponse; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; @@ -19,6 +20,8 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; */ class Response extends SwooleResponse { + private const SHOW_SENSITIVE_CONTEXT_KEY = '__appwrite_response_show_sensitive'; + // General public const MODEL_NONE = 'none'; public const MODEL_ANY = 'any'; @@ -509,7 +512,7 @@ class Response extends SwooleResponse $isPrivilegedUser = $user->isPrivileged($roles); $isAppUser = $user->isApp($roles); - if ((!$isPrivilegedUser && !$isAppUser) && !self::$showSensitive) { + if ((!$isPrivilegedUser && !$isAppUser) && !self::isShowingSensitive()) { $data->setAttribute($key, ''); } } @@ -666,14 +669,36 @@ class Response extends SwooleResponse */ public static function showSensitive(callable $callback): array { + $previous = self::isShowingSensitive(); + try { - self::$showSensitive = true; + self::setShowSensitive(true); return $callback(); } finally { - self::$showSensitive = false; + self::setShowSensitive($previous); } } + private static function isShowingSensitive(): bool + { + if (Coroutine::getCid() !== -1) { + return (bool) (Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] ?? false); + } + + return self::$showSensitive; + } + + private static function setShowSensitive(bool $value): void + { + if (Coroutine::getCid() !== -1) { + Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] = $value; + + return; + } + + self::$showSensitive = $value; + } + private ?Authorization $authorization = null; private ?DBUser $user = null; diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 78a3717c38..d5cd5d800a 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -147,6 +147,21 @@ class RequestTest extends TestCase $this->assertSame('unexpected', $params['extra']); } + public function testRouteIsScopedToRequestInstance(): void + { + $firstRequest = new Request(new SwooleRequest()); + $secondRequest = new Request(new SwooleRequest()); + + $firstRoute = new Route(Request::METHOD_GET, '/first'); + $secondRoute = new Route(Request::METHOD_GET, '/second'); + + $firstRequest->setRoute($firstRoute); + $secondRequest->setRoute($secondRoute); + + $this->assertSame($firstRoute, $firstRequest->getRoute()); + $this->assertSame($secondRoute, $secondRequest->getRoute()); + } + /** * Helper to attach a route with multiple SDK methods to the request. */ diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index 452119fafb..d5c3a079cd 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Utopia; use Appwrite\Utopia\Response; use Exception; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use Swoole\Http\Response as SwooleResponse; use Tests\Unit\Utopia\Response\Filters\First; use Tests\Unit\Utopia\Response\Filters\Second; @@ -176,4 +177,26 @@ class ResponseTest extends TestCase $this->assertArrayHasKey('required', $single); $this->assertArrayNotHasKey('hidden', $singleFromArray); } + + public function testShowSensitiveRestoresPreviousState(): void + { + $isShowingSensitive = new ReflectionMethod(Response::class, 'isShowingSensitive'); + + $this->assertFalse($isShowingSensitive->invoke(null)); + + $payload = Response::showSensitive(function () use ($isShowingSensitive) { + return [ + 'outer' => $isShowingSensitive->invoke(null), + 'inner' => Response::showSensitive(fn () => [ + 'state' => $isShowingSensitive->invoke(null), + ]), + 'afterInner' => $isShowingSensitive->invoke(null), + ]; + }); + + $this->assertTrue($payload['outer']); + $this->assertTrue($payload['inner']['state']); + $this->assertTrue($payload['afterInner']); + $this->assertFalse($isShowingSensitive->invoke(null)); + } } From b8eb0810c2d94ac4061311fbbefead5f8729f66e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 10:24:32 +0530 Subject: [PATCH 13/13] Make response sensitive mode instance-scoped --- app/controllers/api/account.php | 8 +++---- src/Appwrite/Utopia/Response.php | 37 ++++++------------------------ tests/unit/Utopia/ResponseTest.php | 18 +++++++-------- 3 files changed, 20 insertions(+), 43 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d576bbce44..fb968d3972 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3937,7 +3937,7 @@ Http::post('/v1/account/recovery') ->setParam('userId', $profile->getId()) ->setParam('tokenId', $recovery->getId()) ->setUser($profile) - ->setPayload(Response::showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -4038,7 +4038,7 @@ Http::put('/v1/account/recovery') $queueForEvents ->setParam('userId', $profile->getId()) ->setParam('tokenId', $recoveryDocument->getId()) - ->setPayload(Response::showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']); $response->dynamic($recoveryDocument, Response::MODEL_TOKEN); }); @@ -4268,7 +4268,7 @@ Http::post('/v1/account/verifications/email') $queueForEvents ->setParam('userId', $user->getId()) ->setParam('tokenId', $verification->getId()) - ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -4360,7 +4360,7 @@ Http::put('/v1/account/verifications/email') $queueForEvents ->setParam('userId', $userId) ->setParam('tokenId', $verification->getId()) - ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); $response->dynamic($verification, Response::MODEL_TOKEN); }); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 649b0562a5..9d0e8abefa 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -8,7 +8,6 @@ use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Model; use Exception; use JsonException; -use Swoole\Coroutine; use Swoole\Http\Response as SwooleHTTPResponse; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; @@ -20,8 +19,6 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; */ class Response extends SwooleResponse { - private const SHOW_SENSITIVE_CONTEXT_KEY = '__appwrite_response_show_sensitive'; - // General public const MODEL_NONE = 'none'; public const MODEL_ANY = 'any'; @@ -302,7 +299,7 @@ class Response extends SwooleResponse /** * @var bool */ - protected static bool $showSensitive = false; + protected bool $showSensitive = false; /** * @var array @@ -512,7 +509,7 @@ class Response extends SwooleResponse $isPrivilegedUser = $user->isPrivileged($roles); $isAppUser = $user->isApp($roles); - if ((!$isPrivilegedUser && !$isAppUser) && !self::isShowingSensitive()) { + if ((!$isPrivilegedUser && !$isAppUser) && !$this->showSensitive) { $data->setAttribute($key, ''); } } @@ -662,43 +659,23 @@ class Response extends SwooleResponse } /** - * Static wrapper to show sensitive data in response + * Wrapper to show sensitive data in response * * @param callable(): array $callback The callback to show sensitive information for * @return array */ - public static function showSensitive(callable $callback): array + public function showSensitive(callable $callback): array { - $previous = self::isShowingSensitive(); + $previous = $this->showSensitive; try { - self::setShowSensitive(true); + $this->showSensitive = true; return $callback(); } finally { - self::setShowSensitive($previous); + $this->showSensitive = $previous; } } - private static function isShowingSensitive(): bool - { - if (Coroutine::getCid() !== -1) { - return (bool) (Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] ?? false); - } - - return self::$showSensitive; - } - - private static function setShowSensitive(bool $value): void - { - if (Coroutine::getCid() !== -1) { - Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] = $value; - - return; - } - - self::$showSensitive = $value; - } - private ?Authorization $authorization = null; private ?DBUser $user = null; diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index d5c3a079cd..be8cfdc216 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -5,7 +5,7 @@ namespace Tests\Unit\Utopia; use Appwrite\Utopia\Response; use Exception; use PHPUnit\Framework\TestCase; -use ReflectionMethod; +use ReflectionProperty; use Swoole\Http\Response as SwooleResponse; use Tests\Unit\Utopia\Response\Filters\First; use Tests\Unit\Utopia\Response\Filters\Second; @@ -180,23 +180,23 @@ class ResponseTest extends TestCase public function testShowSensitiveRestoresPreviousState(): void { - $isShowingSensitive = new ReflectionMethod(Response::class, 'isShowingSensitive'); + $isShowingSensitive = new ReflectionProperty(Response::class, 'showSensitive'); - $this->assertFalse($isShowingSensitive->invoke(null)); + $this->assertFalse($isShowingSensitive->getValue($this->response)); - $payload = Response::showSensitive(function () use ($isShowingSensitive) { + $payload = $this->response->showSensitive(function () use ($isShowingSensitive) { return [ - 'outer' => $isShowingSensitive->invoke(null), - 'inner' => Response::showSensitive(fn () => [ - 'state' => $isShowingSensitive->invoke(null), + 'outer' => $isShowingSensitive->getValue($this->response), + 'inner' => $this->response->showSensitive(fn () => [ + 'state' => $isShowingSensitive->getValue($this->response), ]), - 'afterInner' => $isShowingSensitive->invoke(null), + 'afterInner' => $isShowingSensitive->getValue($this->response), ]; }); $this->assertTrue($payload['outer']); $this->assertTrue($payload['inner']['state']); $this->assertTrue($payload['afterInner']); - $this->assertFalse($isShowingSensitive->invoke(null)); + $this->assertFalse($isShowingSensitive->getValue($this->response)); } }