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/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/app/controllers/general.php b/app/controllers/general.php index 3bf5f027f2..d10ad9a060 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)) { @@ -1493,6 +1493,19 @@ Http::error() 'type' => $type, ]; + // 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 ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') ->addHeader('Expires', '0') 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. 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..58433c7deb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -130,10 +130,25 @@ class Create extends CollectionAction $indexes[] = new Document($index); } try { - if (!$dbForDatabases->exists(null, Database::METADATA)) { + // Bootstrap the database metadata without a separate existence + // check to avoid races when multiple first collections are created + // concurrently for the same VectorsDB database. + 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( 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]; 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..88f577eac6 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -125,10 +125,7 @@ class OpenAPI3 extends Format $namespace = $sdk->getNamespace() ?? 'default'; - 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 +190,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..f9c79431f0 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -126,10 +126,7 @@ class Swagger2 extends Format $sdkPlatforms = array_values(array_unique($sdkPlatforms)); $namespace = $sdk->getNamespace() ?? 'default'; - 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 +198,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(), ]; 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..9d0e8abefa 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -299,7 +299,7 @@ class Response extends SwooleResponse /** * @var bool */ - protected static bool $showSensitive = false; + protected bool $showSensitive = false; /** * @var array @@ -509,7 +509,7 @@ class Response extends SwooleResponse $isPrivilegedUser = $user->isPrivileged($roles); $isAppUser = $user->isApp($roles); - if ((!$isPrivilegedUser && !$isAppUser) && !self::$showSensitive) { + if ((!$isPrivilegedUser && !$isAppUser) && !$this->showSensitive) { $data->setAttribute($key, ''); } } @@ -659,18 +659,20 @@ 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 = $this->showSensitive; + try { - self::$showSensitive = true; + $this->showSensitive = true; return $callback(); } finally { - self::$showSensitive = false; + $this->showSensitive = $previous; } } 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); } 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..be8cfdc216 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 ReflectionProperty; 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 ReflectionProperty(Response::class, 'showSensitive'); + + $this->assertFalse($isShowingSensitive->getValue($this->response)); + + $payload = $this->response->showSensitive(function () use ($isShowingSensitive) { + return [ + 'outer' => $isShowingSensitive->getValue($this->response), + 'inner' => $this->response->showSensitive(fn () => [ + 'state' => $isShowingSensitive->getValue($this->response), + ]), + 'afterInner' => $isShowingSensitive->getValue($this->response), + ]; + }); + + $this->assertTrue($payload['outer']); + $this->assertTrue($payload['inner']['state']); + $this->assertTrue($payload['afterInner']); + $this->assertFalse($isShowingSensitive->getValue($this->response)); + } }