diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f44d5eedf3..2aeae21655 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,27 @@ jobs: - name: Run PHPStan run: composer analyze -- --no-progress + specs: + name: Checks / Specs + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: swoole + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Generate specs + run: _APP_STORAGE_LIMIT=5368709120 php app/cli.php specs --version=latest --git=no + locale: name: Checks / Locale runs-on: ubuntu-latest @@ -459,6 +480,10 @@ jobs: _APP_BROWSER_HOST: http://invalid-browser/v1 _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -533,6 +558,10 @@ jobs: _APP_OPTIONS_ABUSE: enabled _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -590,6 +619,10 @@ jobs: env: _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} + _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable diff --git a/README.md b/README.md index ed83252e2f..88d527f060 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Table of Contents: ## Products -- **[Appwrite Auth](https://appwrite.io/docs/products/authentication)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows. +- **[Appwrite Auth](https://appwrite.io/docs/products/auth)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows. - **[Appwrite Databases](https://appwrite.io/docs/products/databases)** - Scalable structured data storage with support for databases, tables, and rows. Includes querying, pagination, indexing, and relationships to model complex application data. diff --git a/app/cli.php b/app/cli.php index b636707f1c..458df2d642 100644 --- a/app/cli.php +++ b/app/cli.php @@ -18,13 +18,15 @@ use Swoole\Timer; use Utopia\Cache\Adapter\Pool as CachePool; use Utopia\Cache\Adapter\Sharding; use Utopia\Cache\Cache; +use Utopia\CLI\Adapters\Generic; +use Utopia\CLI\CLI; use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; -use Utopia\DI\Dependency; +use Utopia\DI\Container; use Utopia\DSN\DSN; use Utopia\Logger\Log; use Utopia\Platform\Service; @@ -47,7 +49,7 @@ require_once __DIR__ . '/controllers/general.php'; global $register; $platform = new Appwrite(); -$args = $platform->getEnv('argv'); +$args = $_SERVER['argv'] ?? []; \array_shift($args); if (! isset($args[0])) { @@ -56,21 +58,15 @@ if (! isset($args[0])) { } $taskName = $args[0]; +$container = new Container(); +$cli = new CLI(new Generic(), $_SERVER['argv'] ?? [], $container); + +$platform->setCli($cli); $platform->init(Service::TYPE_TASK); -$cli = $platform->getCli(); -$setResource = function (string $name, callable $callback, array $injections = []) use ($cli) { - $dependency = new Dependency(); - $dependency->setName($name)->setCallback($callback); - foreach ($injections as $injection) { - $dependency->inject($injection); - } - $cli->setResource($dependency); -}; +$container->set('register', fn () => $register, []); -$setResource('register', fn () => $register, []); - -$setResource('cache', function ($pools) { +$container->set('cache', function ($pools) { $list = Config::getParam('pools-cache', []); $adapters = []; @@ -81,18 +77,18 @@ $setResource('cache', function ($pools) { return new Cache(new Sharding($adapters)); }, ['pools']); -$setResource('pools', function (Registry $register) { +$container->set('pools', function (Registry $register) { return $register->get('pools'); }, ['register']); -$setResource('authorization', function () { +$container->set('authorization', function () { $authorization = new Authorization(); $authorization->disable(); return $authorization; }, []); -$setResource('dbForPlatform', function ($pools, $cache, $authorization) { +$container->set('dbForPlatform', function ($pools, $cache, $authorization) { $sleep = 3; $maxAttempts = 5; $attempts = 0; @@ -135,17 +131,17 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) { return $dbForPlatform; }, ['pools', 'cache', 'authorization']); -$setResource('console', function () { +$container->set('console', function () { return new Document(Config::getParam('console')); }, []); -$setResource( +$container->set( 'isResourceBlocked', fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false, [] ); -$setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) { +$container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) { $databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) { @@ -207,10 +203,10 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c }; }, ['pools', 'dbForPlatform', 'cache', 'authorization']); -$setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { +$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { $database = null; - return function (?Document $project = null) use ($pools, $cache, $database, $authorization) { + return function (?Document $project = null) use ($pools, $cache, &$database, $authorization) { if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { $database->setTenant($project->getSequence()); return $database; @@ -235,41 +231,41 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a return $database; }; }, ['pools', 'cache', 'authorization']); -$setResource('publisher', function (Group $pools) { +$container->set('publisher', function (Group $pools) { return new BrokerPool(publisher: $pools->get('publisher')); }, ['pools']); -$setResource('publisherDatabases', function (BrokerPool $publisher) { +$container->set('publisherDatabases', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); -$setResource('publisherFunctions', function (BrokerPool $publisher) { +$container->set('publisherFunctions', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); -$setResource('publisherMigrations', function (BrokerPool $publisher) { +$container->set('publisherMigrations', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); -$setResource('publisherMessaging', function (BrokerPool $publisher) { +$container->set('publisherMessaging', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); -$setResource('usage', function () { +$container->set('usage', function () { return new UsageContext(); }, []); -$setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( +$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); -$setResource('queueForStatsResources', function (Publisher $publisher) { +$container->set('queueForStatsResources', function (Publisher $publisher) { return new StatsResources($publisher); }, ['publisher']); -$setResource('queueForFunctions', function (Publisher $publisher) { +$container->set('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); -$setResource('queueForDeletes', function (Publisher $publisher) { +$container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); -$setResource('queueForCertificates', function (Publisher $publisher) { +$container->set('queueForCertificates', function (Publisher $publisher) { return new Certificate($publisher); }, ['publisher']); -$setResource('logError', function (Registry $register) { +$container->set('logError', function (Registry $register) { return function (Throwable $error, string $namespace, string $action) use ($register) { Console::error('[Error] Timestamp: ' . date('c', time())); Console::error('[Error] Type: ' . get_class($error)); @@ -321,13 +317,13 @@ $setResource('logError', function (Registry $register) { }; }, ['register']); -$setResource('executor', fn () => new Executor(), []); +$container->set('executor', fn () => new Executor(), []); -$setResource('bus', function (Registry $register) use ($cli) { - return $register->get('bus')->setResolver(fn (string $name) => $cli->getResource($name)); +$container->set('bus', function (Registry $register) use ($container) { + return $register->get('bus')->setResolver(fn (string $name) => $container->get($name)); }, ['register']); -$setResource('telemetry', fn () => new NoTelemetry(), []); +$container->set('telemetry', fn () => new NoTelemetry(), []); $exitCode = 0; diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 84964ac96a..6195c11724 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -594,7 +594,7 @@ $platformCollections = [ 'filters' => [], ], [ - '$id' => ID::custom('key'), + '$id' => ID::custom('key'), // For app platforms 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, @@ -605,7 +605,7 @@ $platformCollections = [ 'filters' => [], ], [ - '$id' => ID::custom('store'), + '$id' => ID::custom('store'), // Unused at the moment 'type' => Database::VAR_STRING, 'format' => '', 'size' => 256, @@ -616,7 +616,7 @@ $platformCollections = [ 'filters' => [], ], [ - '$id' => ID::custom('hostname'), + '$id' => ID::custom('hostname'), // For web platforms 'type' => Database::VAR_STRING, 'format' => '', 'size' => 256, diff --git a/app/config/errors.php b/app/config/errors.php index 03fdc2bcc5..4190c6e277 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1179,6 +1179,16 @@ return [ 'description' => 'Platform with the requested ID could not be found.', 'code' => 404, ], + Exception::PLATFORM_METHOD_UNSUPPORTED => [ + 'name' => Exception::PLATFORM_METHOD_UNSUPPORTED, + 'description' => 'The requested platform has invalid type. Please use corresponding update method for the platform type.', + 'code' => 400, + ], + Exception::PLATFORM_ALREADY_EXISTS => [ + 'name' => Exception::PLATFORM_ALREADY_EXISTS, + 'description' => 'Platform with the same ID already exists in this project. Try again with a different ID.', + 'code' => 409, + ], Exception::VARIABLE_NOT_FOUND => [ 'name' => Exception::VARIABLE_NOT_FOUND, 'description' => 'Variable with the requested ID could not be found.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index cbdf11225a..8eb49ea27b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1103,14 +1103,14 @@ Http::post('/v1/account/sessions/email') ])); } - $dbForProject->purgeCachedDocument('users', $user->getId()); - $session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [ Permission::read(Role::user($user->getId())), Permission::update(Role::user($user->getId())), Permission::delete(Role::user($user->getId())), ])); + $dbForProject->purgeCachedDocument('users', $user->getId()); + $encoded = $store ->setProperty('id', $user->getId()) ->setProperty('secret', $secret) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 4eb537d923..eb9ea59e2f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1093,272 +1093,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -// Platforms - -Http::post('/v1/projects/:projectId/platforms') - ->desc('Create platform') - ->groups(['api', 'projects']) - ->label('audits.event', 'platforms.create') - ->label('audits.resource', 'project/{request.projectId}') - ->label('scope', 'platforms.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'platforms', - name: 'createPlatform', - description: '/docs/references/projects/create-platform.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_PLATFORM, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param( - 'type', - null, - new WhiteList([ - Platform::TYPE_WEB, - Platform::TYPE_FLUTTER_WEB, - Platform::TYPE_FLUTTER_IOS, - Platform::TYPE_FLUTTER_ANDROID, - Platform::TYPE_FLUTTER_LINUX, - Platform::TYPE_FLUTTER_MACOS, - Platform::TYPE_FLUTTER_WINDOWS, - Platform::TYPE_APPLE_IOS, - Platform::TYPE_APPLE_MACOS, - Platform::TYPE_APPLE_WATCHOS, - Platform::TYPE_APPLE_TVOS, - Platform::TYPE_ANDROID, - Platform::TYPE_UNITY, - Platform::TYPE_REACT_NATIVE_IOS, - Platform::TYPE_REACT_NATIVE_ANDROID, - ], true), - 'Platform type. Possible values are: web, flutter-web, flutter-ios, flutter-android, flutter-linux, flutter-macos, flutter-windows, apple-ios, apple-macos, apple-watchos, apple-tvos, android, unity, react-native-ios, react-native-android.' - ) - ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', true) - ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true) - ->param('hostname', '', new Hostname(), 'Platform client hostname. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $name, string $key, string $store, string $hostname, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $platform = new Document([ - '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'projectInternalId' => $project->getSequence(), - 'projectId' => $project->getId(), - 'type' => $type, - 'name' => $name, - 'key' => $key, - 'store' => $store, - 'hostname' => $hostname - ]); - - $platform = $dbForPlatform->createDocument('platforms', $platform); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($platform, Response::MODEL_PLATFORM); - }); - -Http::get('/v1/projects/:projectId/platforms') - ->desc('List platforms') - ->groups(['api', 'projects']) - ->label('scope', 'platforms.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'platforms', - name: 'listPlatforms', - description: '/docs/references/projects/list-platforms.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PLATFORM_LIST, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $platforms = $dbForPlatform->find('platforms', [ - Query::equal('projectInternalId', [$project->getSequence()]), - Query::limit(5000), - ]); - - $response->dynamic(new Document([ - 'platforms' => $platforms, - 'total' => $includeTotal ? count($platforms) : 0, - ]), Response::MODEL_PLATFORM_LIST); - }); - -Http::get('/v1/projects/:projectId/platforms/:platformId') - ->desc('Get platform') - ->groups(['api', 'projects']) - ->label('scope', 'platforms.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'platforms', - name: 'getPlatform', - description: '/docs/references/projects/get-platform.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PLATFORM, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $platform = $dbForPlatform->findOne('platforms', [ - Query::equal('$id', [$platformId]), - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - - if ($platform->isEmpty()) { - throw new Exception(Exception::PLATFORM_NOT_FOUND); - } - - $response->dynamic($platform, Response::MODEL_PLATFORM); - }); - -Http::put('/v1/projects/:projectId/platforms/:platformId') - ->desc('Update platform') - ->groups(['api', 'projects']) - ->label('scope', 'platforms.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'platforms', - name: 'updatePlatform', - description: '/docs/references/projects/update-platform.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PLATFORM, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform']) - ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('key', '', new Text(256), 'Package name for android or bundle ID for iOS. Max length: 256 chars.', true) - ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true) - ->param('hostname', '', new Hostname(), 'Platform client URL. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $platformId, string $name, string $key, string $store, string $hostname, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $platform = $dbForPlatform->findOne('platforms', [ - Query::equal('$id', [$platformId]), - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - - if ($platform->isEmpty()) { - throw new Exception(Exception::PLATFORM_NOT_FOUND); - } - - $platform - ->setAttribute('name', $name) - ->setAttribute('key', $key) - ->setAttribute('store', $store) - ->setAttribute('hostname', $hostname); - - $dbForPlatform->updateDocument('platforms', $platform->getId(), $platform); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->dynamic($platform, Response::MODEL_PLATFORM); - }); - -Http::delete('/v1/projects/:projectId/platforms/:platformId') - ->desc('Delete platform') - ->groups(['api', 'projects']) - ->label('audits.event', 'platforms.delete') - ->label('audits.resource', 'project/{request.projectId}/platform/${request.platformId}') - ->label('scope', 'platforms.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'platforms', - name: 'deletePlatform', - description: '/docs/references/projects/delete-platform.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $platform = $dbForPlatform->findOne('platforms', [ - Query::equal('$id', [$platformId]), - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - - if ($platform->isEmpty()) { - throw new Exception(Exception::PLATFORM_NOT_FOUND); - } - - $dbForPlatform->deleteDocument('platforms', $platformId); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->noContent(); - }); - - // CUSTOM SMTP and Templates Http::patch('/v1/projects/:projectId/smtp') ->desc('Update SMTP') diff --git a/app/controllers/general.php b/app/controllers/general.php index 5a5c2dd507..c6e2eacb33 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -752,11 +752,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } if (\is_array($values)) { - $count = 0; foreach ($values as $value) { - $override = $count === 0; - $response->addHeader($name, $value, override: $override); - $count++; + $response->addHeader($name, $value); } } else { $response->addHeader($name, $values); @@ -1474,7 +1471,9 @@ Http::error() try { $cors = $utopia->getResource('cors'); foreach ($cors->headers($request->getOrigin()) as $name => $value) { - $response->addHeader($name, $value, override: true); + $response + ->removeHeader($name) + ->addHeader($name, $value); } } catch (Throwable) { // Degrade gracefully - error response without CORS is no worse than before. diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5166429e32..8254a22ac0 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -98,6 +98,9 @@ Http::init() ->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) { $route = $utopia->getRoute(); + if ($route === null) { + throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); + } /** * Handle user authentication and session validation. @@ -489,6 +492,10 @@ Http::init() $request->setUser($user); $route = $utopia->getRoute(); + if ($route === null) { + throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); + } + $path = $route->getMatchedPath(); $databaseType = match (true) { str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, diff --git a/app/http.php b/app/http.php index 4532839727..67da67376d 100644 --- a/app/http.php +++ b/app/http.php @@ -1,16 +1,13 @@ column('value', Table::TYPE_INT, 1); $certifiedDomains->create(); -Http::setResource('riskyDomains', fn () => $riskyDomains); -Http::setResource('certifiedDomains', fn () => $certifiedDomains); - -$http = new Server( - host: "0.0.0.0", - port: System::getEnv('PORT', 80), - mode: SWOOLE_PROCESS, -); +global $container; +$container->set('riskyDomains', fn () => $riskyDomains); +$container->set('certifiedDomains', fn () => $certifiedDomains); +$container->set('pools', function ($register) { + return $register->get('pools'); +}, ['register']); $payloadSize = 12 * (1024 * 1024); // 12MB - adding slight buffer for headers and other data that might be sent with the payload - update later with valid testing $totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); +$swooleAdapter = new Server( + host: "0.0.0.0", + port: System::getEnv('PORT', 80), + settings: [ + Constant::OPTION_WORKER_NUM => $totalWorkers, + Constant::OPTION_DISPATCH_FUNC => dispatch(...), + Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD, + Constant::OPTION_HTTP_COMPRESSION => false, + Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize, + Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize, + Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background + ], + container: $container, +); + +$container->set('container', fn () => fn () => $swooleAdapter->getContainer()); + +$http = $swooleAdapter->getServer(); + /** * Assigns HTTP requests to worker threads by analyzing its payload/content. * @@ -69,16 +84,16 @@ $totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intva * riskier tasks to a dedicated worker subset. Prefers idle workers, with fallback to random selection if necessary. * doc: https://openswoole.com/docs/modules/swoole-server/configuration#dispatch_func * - * @param Server $server Swoole server instance. + * @param \Swoole\Http\Server $server Swoole server instance. * @param int $fd client ID * @param int $type the type of data and its current state * @param string|null $data Request content for categorization. * @global int $totalThreads Total number of workers. * @return int Chosen worker ID for the request. */ -function dispatch(Server $server, int $fd, int $type, $data = null): int +function dispatch(\Swoole\Http\Server $server, int $fd, int $type, $data = null): int { - $resolveWorkerId = function (Server $server, $data = null) { + $resolveWorkerId = function (\Swoole\Http\Server $server, $data = null) { global $totalWorkers, $riskyDomains; // If data is not set we can send request to any worker @@ -161,18 +176,6 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int return $workerId; } - -$http - ->set([ - Constant::OPTION_WORKER_NUM => $totalWorkers, - Constant::OPTION_DISPATCH_FUNC => dispatch(...), - Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD, - Constant::OPTION_HTTP_COMPRESSION => false, - Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize, - Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize, - Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background - ]); - $http->on(Constant::EVENT_WORKER_START, function ($server, $workerId) { }); @@ -189,16 +192,14 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) { Console::success('Reload completed...'); }); -Http::setResource('bus', function ($register, $utopia) { - return $register->get('bus')->setResolver(fn (string $name) => $utopia->getResource($name)); -}, ['register', 'utopia']); +$container->set('bus', function ($register) use ($swooleAdapter) { + return $register->get('bus')->setResolver(fn (string $name) => $swooleAdapter->getContainer()->get($name)); +}, ['register']); include __DIR__ . '/controllers/general.php'; function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void { - $max = 15; - $sleep = 2; $max = 15; $sleep = 2; $attempts = 0; @@ -289,13 +290,13 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c Span::current()?->finish(); } -$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $totalWorkers, $register) { - $app = new Http('UTC'); +$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $swooleAdapter) { + $app = new Http($swooleAdapter, 'UTC'); - go(function () use ($register, $app) { - $pools = $register->get('pools'); - /** @var \Utopia\Pools\Group $pools */ - Http::setResource('pools', fn () => $pools); + /** @var \Utopia\Pools\Group $pools */ + $pools = $app->getResource('pools'); + + go(function () use ($app, $pools) { /** @var array $collections */ $collections = Config::getParam('collections', []); @@ -511,14 +512,11 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot }); }); -$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) use ($register, $files) { +$swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swooleAdapter, $registerRequestResources) { Span::init('http.request'); - Http::setResource('swooleRequest', fn () => $swooleRequest); - Http::setResource('swooleResponse', fn () => $swooleResponse); - - $request = new Request($swooleRequest); - $response = new Response($swooleResponse); + $request = new Request($utopiaRequest->getSwooleRequest()); + $response = new Response($utopiaResponse->getSwooleResponse()); Span::add('http.method', $request->getMethod()); @@ -534,13 +532,18 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool return; } - $app = new Http('UTC'); + $requestContainer = $swooleAdapter->getContainer(); + $requestContainer->set('request', fn () => $request); + $requestContainer->set('response', fn () => $response); + + $app = new Http($swooleAdapter, 'UTC'); + $requestContainer->set('utopia', fn () => $app); + + $registerRequestResources($requestContainer); + $app->setCompression(System::getEnv('_APP_COMPRESSION_ENABLED', 'enabled') === 'enabled'); $app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB - $pools = $register->get('pools'); - Http::setResource('pools', fn () => $pools); - try { $authorization = $app->getResource('authorization'); @@ -624,6 +627,7 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool } } + $swooleResponse = $utopiaResponse->getSwooleResponse(); $swooleResponse->setStatusCode(500); $output = ((Http::isDevelopment())) ? [ @@ -647,11 +651,10 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool }); // Fetch domains every `DOMAIN_SYNC_TIMER` seconds and update in the memory -$http->on(Constant::EVENT_TASK, function () use ($register) { +$http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) { $lastSyncUpdate = null; - $pools = $register->get('pools'); - Http::setResource('pools', fn () => $pools); - $app = new Http('UTC'); + + $app = new Http($swooleAdapter, 'UTC'); /** @var Utopia\Database\Database $dbForPlatform */ $dbForPlatform = $app->getResource('dbForPlatform'); @@ -726,4 +729,4 @@ $http->on(Constant::EVENT_TASK, function () use ($register) { }); }); -$http->start(); +$swooleAdapter->start(); diff --git a/app/init/models.php b/app/init/models.php index bf6d67dd95..dd97b03652 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -106,7 +106,12 @@ use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; use Appwrite\Utopia\Response\Model\Phone; -use Appwrite\Utopia\Response\Model\Platform; +use Appwrite\Utopia\Response\Model\PlatformAndroid; +use Appwrite\Utopia\Response\Model\PlatformApple; +use Appwrite\Utopia\Response\Model\PlatformLinux; +use Appwrite\Utopia\Response\Model\PlatformList; +use Appwrite\Utopia\Response\Model\PlatformWeb; +use Appwrite\Utopia\Response\Model\PlatformWindows; use Appwrite\Utopia\Response\Model\Preferences; use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; @@ -197,7 +202,6 @@ Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, ' Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true)); Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false)); Response::setModel(new BaseList('Auth Providers List', Response::MODEL_AUTH_PROVIDER_LIST, 'platforms', Response::MODEL_AUTH_PROVIDER, true, false)); -Response::setModel(new BaseList('Platforms List', Response::MODEL_PLATFORM_LIST, 'platforms', Response::MODEL_PLATFORM, true, false)); Response::setModel(new BaseList('Countries List', Response::MODEL_COUNTRY_LIST, 'countries', Response::MODEL_COUNTRY)); Response::setModel(new BaseList('Continents List', Response::MODEL_CONTINENT_LIST, 'continents', Response::MODEL_CONTINENT)); Response::setModel(new BaseList('Languages List', Response::MODEL_LANGUAGE_LIST, 'languages', Response::MODEL_LANGUAGE)); @@ -333,7 +337,12 @@ Response::setModel(new Key()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new AuthProvider()); -Response::setModel(new Platform()); +Response::setModel(new PlatformWeb()); +Response::setModel(new PlatformApple()); +Response::setModel(new PlatformAndroid()); +Response::setModel(new PlatformWindows()); +Response::setModel(new PlatformLinux()); +Response::setModel(new PlatformList()); Response::setModel(new Variable()); Response::setModel(new Country()); Response::setModel(new Continent()); diff --git a/app/init/registers.php b/app/init/registers.php index 68ccbd2097..c07bc9da8b 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -245,19 +245,8 @@ $register->set('pools', function () { $maxConnections = (int) System::getEnv('_APP_CONNECTIONS_MAX', 151); $instanceConnections = $maxConnections / (int) System::getEnv('_APP_POOL_CLIENTS', 14); - $multiprocessing = System::getEnv('_APP_SERVER_MULTIPROCESS', 'disabled') === 'enabled'; - - if ($multiprocessing) { - $workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); - } else { - $workerCount = 1; - } - - if ($workerCount > $instanceConnections) { - throw new \Exception('Pool size is too small. Increase the number of allowed database connections or decrease the number of workers.', 500); - } - - $poolSize = (int)($instanceConnections / $workerCount); + $workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); + $poolSize = max(1, (int)($instanceConnections / $workerCount)); foreach ($connections as $key => $connection) { $type = $connection['type'] ?? ''; diff --git a/app/init/resources.php b/app/init/resources.php index 92164c3c95..fdca88c30e 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1,47 +1,10 @@ new Log()); -Http::setResource('logger', function ($register) { +global $register; +global $container; +$container = new Container(); + +$container->set('logger', function ($register) { return $register->get('logger'); }, ['register']); -Http::setResource('hooks', function ($register) { +$container->set('hooks', function ($register) { return $register->get('hooks'); }, ['register']); -global $register; -Http::setResource('register', fn () => $register); -Http::setResource('locale', function () { - $locale = new Locale(System::getEnv('_APP_LOCALE', 'en')); - $locale->setFallback(System::getEnv('_APP_LOCALE', 'en')); +$container->set('register', fn () => $register); - return $locale; -}); - -Http::setResource('localeCodes', function () { +$container->set('localeCodes', function () { return array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', [])); }); -// Queues -Http::setResource('publisher', function (Group $pools) { +// Queues - shared infrastructure (stateless pool wrappers) +$container->set('publisher', function (Group $pools) { return new BrokerPool(publisher: $pools->get('publisher')); }, ['pools']); -Http::setResource('publisherDatabases', function (Publisher $publisher) { +$container->set('publisherDatabases', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('publisherFunctions', function (Publisher $publisher) { +$container->set('publisherFunctions', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('publisherMigrations', function (Publisher $publisher) { +$container->set('publisherMigrations', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('publisherMails', function (Publisher $publisher) { +$container->set('publisherMails', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('publisherDeletes', function (Publisher $publisher) { +$container->set('publisherDeletes', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('publisherMessaging', function (Publisher $publisher) { +$container->set('publisherMessaging', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('publisherWebhooks', function (Publisher $publisher) { +$container->set('publisherWebhooks', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('queueForMessaging', function (Publisher $publisher) { - return new Messaging($publisher); -}, ['publisher']); -Http::setResource('queueForMails', function (Publisher $publisher) { - return new Mail($publisher); -}, ['publisher']); -Http::setResource('queueForBuilds', function (Publisher $publisher) { - return new Build($publisher); -}, ['publisher']); -Http::setResource('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); -}, ['publisher']); -Http::setResource('queueForDatabase', function (Publisher $publisher) { - return new EventDatabase($publisher); -}, ['publisher']); -Http::setResource('queueForDeletes', function (Publisher $publisher) { - return new Delete($publisher); -}, ['publisher']); -Http::setResource('queueForEvents', function (Publisher $publisher) { - return new Event($publisher); -}, ['publisher']); -Http::setResource('queueForWebhooks', function (Publisher $publisher) { - return new Webhook($publisher); -}, ['publisher']); -Http::setResource('queueForRealtime', function () { - return new Realtime(); -}, []); -Http::setResource('usage', function () { - return new UsageContext(); -}, []); -Http::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( +$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); -Http::setResource('queueForAudits', function (Publisher $publisher) { - return new AuditEvent($publisher); -}, ['publisher']); -Http::setResource('queueForFunctions', function (Publisher $publisher) { - return new Func($publisher); -}, ['publisher']); -Http::setResource('eventProcessor', function () { - return new EventProcessor(); -}, []); -Http::setResource('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); -}, ['publisher']); -Http::setResource('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); -}, ['publisher']); -Http::setResource('queueForStatsResources', function (Publisher $publisher) { - return new StatsResources($publisher); -}, ['publisher']); /** * Platform configuration */ -Http::setResource('platform', function () { +$container->set('platform', function () { return Config::getParam('platform', []); }, []); -/** - * List of allowed request hostnames for the request. - */ -Http::setResource('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) { - $allowed = [...($platform['hostnames'] ?? [])]; - - /* Add platform configured hostnames */ - if (! $project->isEmpty() && $project->getId() !== 'console') { - $platforms = $project->getAttribute('platforms', []); - $hostnames = Platform::getHostnames($platforms); - $allowed = [...$allowed, ...$hostnames]; - } - - /* Add the request hostname if a dev key is found */ - if (! $devKey->isEmpty()) { - $allowed[] = $request->getHostname(); - } - - $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST); - $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST); - - $hostname = $originHostname; - if (empty($hostname)) { - $hostname = $refererHostname; - } - - /* Add request hostname for preflight requests */ - if ($request->getMethod() === 'OPTIONS') { - $allowed[] = $hostname; - } - - /* Allow the request origin of rule */ - if (! $rule->isEmpty() && ! empty($rule->getAttribute('domain', ''))) { - $allowed[] = $rule->getAttribute('domain', ''); - } - - /* Allow the request origin if a dev key is found */ - if (! $devKey->isEmpty() && ! empty($hostname)) { - $allowed[] = $hostname; - } - - return array_unique($allowed); -}, ['platform', 'project', 'rule', 'devKey', 'request']); - -/** - * List of allowed request schemes for the request. - */ -Http::setResource('allowedSchemes', function (array $platform, Document $project) { - $allowed = [...($platform['schemas'] ?? [])]; - - if (! $project->isEmpty() && $project->getId() !== 'console') { - /* Add hardcoded schemes */ - $allowed[] = 'exp'; - $allowed[] = 'appwrite-callback-' . $project->getId(); - - /* Add platform configured schemes */ - $platforms = $project->getAttribute('platforms', []); - $schemes = Platform::getSchemes($platforms); - $allowed = [...$allowed, ...$schemes]; - } - - return array_unique($allowed); -}, ['platform', 'project']); - -/** - * Whether the request origin is verified against the request hostname. - */ -Http::setResource('domainVerification', function (Request $request) { - $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST); - $selfDomain = new Domain($request->getHostname()); - $endDomain = new Domain((string) $origin); - - return ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) - && $endDomain->getRegisterable() !== ''; -}, ['request']); - -/** - * Cookie domain for the current request. - */ -Http::setResource('cookieDomain', function (Request $request, Document $project) { - $localHosts = ['localhost', 'localhost:' . $request->getPort()]; - - $migrationHost = System::getEnv('_APP_MIGRATION_HOST'); - if (!empty($migrationHost)) { - // Treat the migration host like localhost because internal migration and CI - // traffic may use it before a public domain is configured. - $localHosts[] = $migrationHost; - $localHosts[] = $migrationHost . ':' . $request->getPort(); - } - - $hostname = $request->getHostname(); - $isLocalHost = \in_array($hostname, $localHosts, true); - $isIpAddress = \filter_var($hostname, FILTER_VALIDATE_IP) !== false; - - if ($isLocalHost || $isIpAddress) { - return; - } - - $isConsoleProject = $project->getAttribute('$id', '') === 'console'; - $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled'; - - if ($isConsoleProject && $isConsoleRootSession) { - $domain = new Domain($hostname); - - return '.' . $domain->getRegisterable(); - } - - return '.' . $hostname; -}, ['request', 'project']); - -/** - * Rule associated with a request origin. - */ -Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) { - $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); - - if (empty($domain)) { - $domain = \parse_url($request->getReferer(), PHP_URL_HOST); - } - - if (empty($domain)) { - return new Document(); - } - - // TODO: (@Meldiron) Remove after 1.7.x migration - $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; - $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { - if ($isMd5) { - return $dbForPlatform->getDocument('rules', md5($domain)); - } - - return $dbForPlatform->findOne('rules', [ - Query::equal('domain', [$domain]), - ]) ?? new Document(); - }); - - $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); - - // Temporary implementation until custom wildcard domains are an official feature - // Allow trusted projects; Used for Console (website) previews - if (! $permitsCurrentProject && ! $rule->isEmpty() && ! empty($rule->getAttribute('projectId', ''))) { - $trustedProjects = []; - foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { - if (empty($trustedProject)) { - continue; - } - $trustedProjects[] = $trustedProject; - } - if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { - $permitsCurrentProject = true; - } - } - - if (! $permitsCurrentProject) { - return new Document(); - } - - return $rule; -}, ['request', 'dbForPlatform', 'project', 'authorization']); - -/** - * CORS service - */ -Http::setResource('cors', function (array $allowedHostnames) { - $corsConfig = Config::getParam('cors'); - - return new Cors( - $allowedHostnames, - allowedMethods: $corsConfig['allowedMethods'], - allowedHeaders: $corsConfig['allowedHeaders'], - allowCredentials: true, - exposedHeaders: $corsConfig['exposedHeaders'], - ); -}, ['allowedHostnames']); - -Http::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { - if (! $devKey->isEmpty()) { - return new URL(); - } - - return new Origin($allowedHostnames, $allowedSchemes); -}, ['devKey', 'allowedHostnames', 'allowedSchemes']); - -Http::setResource('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { - if (! $devKey->isEmpty()) { - return new URL(); - } - - return new Redirect($allowedHostnames, $allowedSchemes); -}, ['devKey', 'allowedHostnames', 'allowedSchemes']); - -Http::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) { - /** - * Handles 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: - * 1. Checks the cookie based on mode: - * - If in admin mode, uses console project id for key. - * - Otherwise, sets the key using the project ID - * 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`. - * - If this method is used, returns the header: `X-Debug-Fallback: true`. - * 3. Fetches the user document from the appropriate database based on the mode. - * 4. If the user document is empty or the session key cannot be verified, sets an empty user document. - * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token. - * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`, - * overwriting the previous value. - * 7. If account API key is passed, use user of the account API key as long as user ID header matches too - */ - $authorization->setDefaultStatus(true); - - $store->setKey('a_session_' . $project->getId()); - - if ($mode === APP_MODE_ADMIN) { - $store->setKey('a_session_' . $console->getId()); - } - - $store->decode( - $request->getCookie( - $store->getKey(), // Get sessions - $request->getCookie($store->getKey() . '_legacy', '') - ) - ); - - // Get session from header for SSR clients - if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { - $sessionHeader = $request->getHeader('x-appwrite-session', ''); - - if (! empty($sessionHeader)) { - $store->decode($sessionHeader); - } - } - - // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies - if ($response) { // if in http context - add debug header - $response->addHeader('X-Debug-Fallback', 'false'); - } - - if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { - if ($response) { - $response->addHeader('X-Debug-Fallback', 'true'); - } - $fallback = $request->getHeader('x-fallback-cookies', ''); - $fallback = \json_decode($fallback, true); - $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); - } - - $user = null; - if ($mode === APP_MODE_ADMIN) { - /** @var User $user */ - $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); - } else { - if ($project->isEmpty()) { - $user = new User([]); - } else { - if (! empty($store->getProperty('id', ''))) { - if ($project->getId() === 'console') { - /** @var User $user */ - $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); - } else { - /** @var User $user */ - $user = $dbForProject->getDocument('users', $store->getProperty('id', '')); - } - } - } - } - - if ( - ! $user || - $user->isEmpty() // Check a document has been found in the DB - || ! $user->sessionVerify($store->getProperty('secret', ''), $proofForToken) - ) { // Validate user has valid login token - $user = new User([]); - } - - $authJWT = $request->getHeader('x-appwrite-jwt', ''); - if (! empty($authJWT) && ! $project->isEmpty()) { // JWT authentication - if (! $user->isEmpty()) { - throw new Exception(Exception::USER_JWT_AND_COOKIE_SET); - } - - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); - try { - $payload = $jwt->decode($authJWT); - } catch (JWTException $error) { - throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); - } - - $jwtUserId = $payload['userId'] ?? ''; - if (! empty($jwtUserId)) { - if ($mode === APP_MODE_ADMIN) { - /** @var User $user */ - $user = $dbForPlatform->getDocument('users', $jwtUserId); - } else { - /** @var User $user */ - $user = $dbForProject->getDocument('users', $jwtUserId); - } - } - $jwtSessionId = $payload['sessionId'] ?? ''; - if (! empty($jwtSessionId)) { - if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token - $user = new User([]); - } - } - } - - // Account based on account API key - $accountKey = $request->getHeader('x-appwrite-key', ''); - $accountKeyUserId = $request->getHeader('x-appwrite-user', ''); - if (! empty($accountKeyUserId) && ! empty($accountKey)) { - if (! $user->isEmpty()) { - throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); - } - - /** @var User $accountKeyUser */ - $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); - if (! $accountKeyUser->isEmpty()) { - $key = $accountKeyUser->find( - key: 'secret', - find: $accountKey, - subject: 'keys' - ); - - if (! empty($key)) { - $expire = $key->getAttribute('expire'); - if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { - throw new Exception(Exception::ACCOUNT_KEY_EXPIRED); - } - - $user = $accountKeyUser; - } - } - } - - // Impersonation: if current user has impersonator capability and headers are set, act as another user - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); - if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { - $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; - $targetUser = null; - if (!empty($impersonateUserId)) { - $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->getDocument('users', $impersonateUserId)); - } elseif (!empty($impersonateEmail)) { - $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('email', [\strtolower($impersonateEmail)])])); - } elseif (!empty($impersonatePhone)) { - $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('phone', [$impersonatePhone])])); - } - if ($targetUser !== null && !$targetUser->isEmpty()) { - $impersonator = clone $user; - $user = clone $targetUser; - $user->setAttribute('impersonatorUserId', $impersonator->getId()); - $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence()); - $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', '')); - $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', '')); - $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0)); - } - } - - $dbForProject->setMetadata('user', $user->getId()); - $dbForPlatform->setMetadata('user', $user->getId()); - - return $user; -}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']); - -Http::setResource('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) { - /** @var Appwrite\Utopia\Request $request */ - /** @var Utopia\Database\Database $dbForPlatform */ - /** @var Utopia\Database\Document $console */ - $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', '')); - // Realtime channel "project" can send project=Query array - if (! \is_string($projectId)) { - $projectId = $request->getHeader('x-appwrite-project', ''); - } - - // Backwards compatibility for new services, originally project resources - // These endpoints moved from /v1/projects/:projectId/ to /v1/ - // When accessed via the old alias path, extract projectId from the URI - $deprecatedProjectPathPrefix = '/v1/projects/'; - $route = $utopia->match($request); - if (!empty($route)) { - $isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) && - !\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix); - - if ($isDeprecatedAlias) { - $projectId = \explode('/', $request->getURI(), 5)[3] ?? ''; - } - } - - if (empty($projectId) || $projectId === 'console') { - return $console; - } - - $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); - - return $project; -}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']); - -Http::setResource('session', function (User $user, Store $store, Token $proofForToken) { - if ($user->isEmpty()) { - return; - } - - $sessions = $user->getAttribute('sessions', []); - $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); - - if (! $sessionId) { - return; - } - foreach ($sessions as $session) { - /** @var Document $session */ - if ($sessionId === $session->getId()) { - return $session; - } - } - -}, ['user', 'store', 'proofForToken']); - -Http::setResource('store', function (): Store { - return new Store(); -}); - -Http::setResource('proofForPassword', function (): Password { - $hash = new Argon2(); - $hash - ->setMemoryCost(7168) - ->setTimeCost(5) - ->setThreads(1); - - $password = new Password(); - $password - ->setHash($hash); - - return $password; -}); - -Http::setResource('proofForToken', function (): Token { - $token = new Token(); - $token->setHash(new Sha()); - - return $token; -}); - -Http::setResource('proofForCode', function (): Code { - $code = new Code(); - $code->setHash(new Sha()); - - return $code; -}); - -Http::setResource('console', function () { +$container->set('console', function () { return new Document(Config::getParam('console')); }, []); -Http::setResource('authorization', function () { +$container->set('authorization', function () { return new Authorization(); }, []); -Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) { - if ($project->isEmpty() || $project->getId() === 'console') { - return $dbForPlatform; - } - - $database = $project->getAttribute('database', ''); - if (empty($database)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured'); - } - - try { - $dsn = new DSN($database); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $database); - } - - $adapter = new DatabasePool($pools->get($dsn->getHost())); - $database = new Database($adapter, $cache); - - $database - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization) - ->setMetadata('host', \gethostname()) - ->setMetadata('project', $project->getId()) - ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) - ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); - $database->setDocumentType('users', User::class); - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn->getHost(), $sharedTables)) { - $database - ->setSharedTables(true) - ->setTenant($project->getSequence()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $database - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - - /** - * This isolated event handling for `users.*.create` which is based on a `Database::EVENT_DOCUMENT_CREATE` listener may look odd, but it is **intentional**. - * - * Accounts can be created in many ways beyond `createAccount` - * (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here. - */ - $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) { - // Only trigger events for user creation with the database listener. - if ($document->getCollection() !== 'users') { - return; - } - - $queueForEvents - ->setEvent('users.[userId].create') - ->setParam('userId', $document->getId()) - ->setPayload($response->output($document, Response::MODEL_USER)); - - // Trigger functions, webhooks, and realtime events - $queueForFunctions - ->from($queueForEvents) - ->trigger(); - - /** Trigger webhooks events only if a project has them enabled */ - if (! empty($project->getAttribute('webhooks'))) { - $queueForWebhooks - ->from($queueForEvents) - ->trigger(); - } - - /** Trigger realtime events only for non console events */ - if ($queueForEvents->getProject()->getId() !== 'console') { - $queueForRealtime - ->from($queueForEvents) - ->trigger(); - } - }; - - /** - * Purge function events cache when functions are created, updated or deleted. - */ - $functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) { - - if ($document->getCollection() !== 'functions') { - return; - } - - if ($project->isEmpty() || $project->getId() === 'console') { - return; - } - - $hostname = $dbForProject->getAdapter()->getHostname(); - $cacheKey = \sprintf( - '%s-cache-%s:%s:%s:project:%s:functions:events', - $dbForProject->getCacheName(), - $hostname, - $dbForProject->getNamespace(), - $dbForProject->getTenant(), - $project->getId() - ); - - $dbForProject->getCache()->purge($cacheKey); - }; - - /** - * Prefix metrics with database type when applicable. - * Avoids prefixing for legacy and tablesdb types to preserve historical metrics. - */ - $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string { - if ( - $databaseType === '' || - $databaseType === DATABASE_TYPE_LEGACY || - $databaseType === DATABASE_TYPE_TABLESDB - ) { - return $metric; - } - - return $databaseType . '.' . $metric; - }; - - // Determine database type from request path, similar to api.php - $path = $request->getURI(); - $databaseType = match (true) { - str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, - str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, - default => '', - }; - - $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) use ($getDatabaseTypePrefixedMetric, $databaseType) { - $value = 1; - - switch ($event) { - case Database::EVENT_DOCUMENT_DELETE: - $value = -1; - break; - case Database::EVENT_DOCUMENTS_DELETE: - $value = -1 * $document->getAttribute('modified', 0); - break; - case Database::EVENT_DOCUMENTS_CREATE: - $value = $document->getAttribute('modified', 0); - break; - case Database::EVENT_DOCUMENTS_UPSERT: - $value = $document->getAttribute('created', 0); - break; - } - - switch (true) { - case $document->getCollection() === 'teams': - $usage->addMetric(METRIC_TEAMS, $value); // per project - break; - case $document->getCollection() === 'users': - $usage->addMetric(METRIC_USERS, $value); // per project - if ($event === Database::EVENT_DOCUMENT_DELETE) { - $usage->addReduce($document); - } - break; - case $document->getCollection() === 'sessions': // sessions - $usage->addMetric(METRIC_SESSIONS, $value); // per project - break; - case $document->getCollection() === 'databases': // databases - $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES); - $usage->addMetric($metric, $value); // per project - - if ($event === Database::EVENT_DOCUMENT_DELETE) { - $usage->addReduce($document); - } - break; - case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections - $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; - $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS); - $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS); - $usage - ->addMetric($collectionMetric, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value); - - if ($event === Database::EVENT_DOCUMENT_DELETE) { - $usage->addReduce($document); - } - break; - case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents - $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; - $collectionInternalId = $parts[3] ?? 0; - $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS); - $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS); - $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS); - $usage - ->addMetric($documentsMetric, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection - break; - case $document->getCollection() === 'buckets': // buckets - $usage->addMetric(METRIC_BUCKETS, $value); // per project - if ($event === Database::EVENT_DOCUMENT_DELETE) { - $usage - ->addReduce($document); - } - break; - case str_starts_with($document->getCollection(), 'bucket_'): // files - $parts = explode('_', $document->getCollection()); - $bucketInternalId = $parts[1]; - $usage - ->addMetric(METRIC_FILES, $value) // per project - ->addMetric(METRIC_FILES_STORAGE, $document->getAttribute('sizeOriginal') * $value) // per project - ->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES), $value) // per bucket - ->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES_STORAGE), $document->getAttribute('sizeOriginal') * $value); // per bucket - break; - case $document->getCollection() === 'functions': - $usage->addMetric(METRIC_FUNCTIONS, $value); // per project - - if ($event === Database::EVENT_DOCUMENT_DELETE) { - $usage - ->addReduce($document); - } - break; - case $document->getCollection() === 'sites': - $usage->addMetric(METRIC_SITES, $value); // per project - - if ($event === Database::EVENT_DOCUMENT_DELETE) { - $usage - ->addReduce($document); - } - break; - case $document->getCollection() === 'deployments': - $usage - ->addMetric(METRIC_DEPLOYMENTS, $value) // per project - ->addMetric(METRIC_DEPLOYMENTS_STORAGE, $document->getAttribute('size') * $value) // per project - ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS), $value) // per function - ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value) - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS), $value) // per function - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value); - break; - default: - break; - } - }; - - // Clone the queues, to prevent events triggered by the database listener - // from overwriting the events that are supposed to be triggered in the shutdown hook. - $queueForEventsClone = new Event($publisher); - $queueForFunctions = new Func($publisherFunctions); - $queueForWebhooks = new Webhook($publisherWebhooks); - $queueForRealtime = new Realtime(); - - $database - ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) - ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) - ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) - ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) - ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) - ->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener( - $project, - $document, - $response, - $queueForEventsClone->from($queueForEvents), - $queueForFunctions->from($queueForEvents), - $queueForWebhooks->from($queueForEvents), - $queueForRealtime->from($queueForEvents) - )) - ->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) - ->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) - ->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)); - - return $database; -}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']); - -Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { +$container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); $database = new Database($adapter, $cache); @@ -934,200 +117,7 @@ Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authori return $database; }, ['pools', 'cache', 'authorization']); -Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) { - - return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database { - $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', '')); - $databaseType = $database->getAttribute('type', ''); - - try { - $databaseDSN = new DSN($databaseDSN); - } catch (\InvalidArgumentException) { - // for old databases migrated through patch script - // databaseDSN determines the adapter - $databaseDSN = new DSN('mysql://'.$databaseDSN); - } - try { - $dsn = new DSN($project->getAttribute('database')); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $project->getAttribute('database')); - } - - $pool = $pools->get($databaseDSN->getHost()); - - $adapter = new DatabasePool($pool); - $database = new Database($adapter, $cache); - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - $database - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization) - ->setMetadata('host', \gethostname()) - ->setMetadata('project', $project->getId()) - ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) - ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); - // inside pools authorization needs to be set first - $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); - if (\in_array($dsn->getHost(), $sharedTables)) { - $database - ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $database - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - $timeout = \intval($request->getHeader('x-appwrite-timeout')); - if (!empty($timeout) && Http::isDevelopment()) { - $database->setTimeout($timeout); - } - - // Register database event listeners for usage stats collection - $documentsMetric = METRIC_DOCUMENTS; - $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; - $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; - if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { - $documentsMetric = $databaseType. '.' .$documentsMetric; - $databaseIdDocumentsMetric = $databaseType. '.' .$databaseIdDocumentsMetric; - $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' .$databaseIdCollectionIdDocumentsMetric; - } - $database - ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { - $value = 1; - - if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { - $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; - $collectionInternalId = $parts[3] ?? 0; - $usage - ->addMetric($documentsMetric, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection - } - }) - ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { - $value = -1; - - if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { - $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; - $collectionInternalId = $parts[3] ?? 0; - $usage - ->addMetric($documentsMetric, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection - } - }) - ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { - $value = $document->getAttribute('modified', 0); - - if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { - $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; - $collectionInternalId = $parts[3] ?? 0; - $usage - ->addMetric($documentsMetric, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection - } - }) - ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { - $value = -1 * $document->getAttribute('modified', 0); - - if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { - $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; - $collectionInternalId = $parts[3] ?? 0; - $usage - ->addMetric($documentsMetric, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection - } - }) - ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { - $value = $document->getAttribute('created', 0); - - if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { - $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; - $collectionInternalId = $parts[3] ?? 0; - $usage - ->addMetric($documentsMetric, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection - } - }); - - return $database; - }; - -}, ['pools','cache','project','request','usage','authorization']); - -Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) { - $databases = []; - - return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) { - if ($project->isEmpty() || $project->getId() === 'console') { - return $dbForPlatform; - } - - $database = $project->getAttribute('database', ''); - if (empty($database)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured'); - } - - try { - $dsn = new DSN($database); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $database); - } - - $configure = (function (Database $database) use ($project, $dsn, $authorization) { - $database - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization) - ->setMetadata('host', \gethostname()) - ->setMetadata('project', $project->getId()) - ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) - ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES) - ->setDocumentType('users', User::class); - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn->getHost(), $sharedTables)) { - $database - ->setSharedTables(true) - ->setTenant($project->getSequence()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $database - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - }); - - if (isset($databases[$dsn->getHost()])) { - $database = $databases[$dsn->getHost()]; - $configure($database); - - return $database; - } - - $adapter = new DatabasePool($pools->get($dsn->getHost())); - $database = new Database($adapter, $cache); - $databases[$dsn->getHost()] = $database; - $configure($database); - - return $database; - }; -}, ['pools', 'dbForPlatform', 'cache', 'authorization']); - -Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { +$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { $database = null; return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) { @@ -1156,15 +146,9 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati }; }, ['pools', 'cache', 'authorization']); -Http::setResource('audit', function ($dbForProject) { - $adapter = new AdapterDatabase($dbForProject); +$container->set('telemetry', fn () => new NoTelemetry()); - return new Audit($adapter); -}, ['dbForProject']); - -Http::setResource('telemetry', fn () => new NoTelemetry()); - -Http::setResource('cache', function (Group $pools, Telemetry $telemetry) { +$container->set('cache', function (Group $pools, Telemetry $telemetry) { $list = Config::getParam('pools-cache', []); $adapters = []; @@ -1178,7 +162,7 @@ Http::setResource('cache', function (Group $pools, Telemetry $telemetry) { return $cache; }, ['pools', 'telemetry']); -Http::setResource('redis', function () { +$container->set('redis', function () { $host = System::getEnv('_APP_REDIS_HOST', 'localhost'); $port = System::getEnv('_APP_REDIS_PORT', 6379); $pass = System::getEnv('_APP_REDIS_PASS', ''); @@ -1193,31 +177,15 @@ Http::setResource('redis', function () { return $redis; }); -Http::setResource('timelimit', function (\Redis $redis) { +$container->set('timelimit', function (\Redis $redis) { return function (string $key, int $limit, int $time) use ($redis) { return new TimeLimitRedis($key, $limit, $time, $redis); }; }, ['redis']); -Http::setResource('deviceForLocal', function (Telemetry $telemetry) { +$container->set('deviceForLocal', function (Telemetry $telemetry) { return new Device\Telemetry($telemetry, new Local()); }, ['telemetry']); -Http::setResource('deviceForFiles', function ($project, Telemetry $telemetry) { - return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); -Http::setResource('deviceForSites', function ($project, Telemetry $telemetry) { - return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); -}, ['project', 'telemetry']); -Http::setResource('deviceForMigrations', function ($project, Telemetry $telemetry) { - return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); -Http::setResource('deviceForFunctions', function ($project, Telemetry $telemetry) { - return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); -Http::setResource('deviceForBuilds', function ($project, Telemetry $telemetry) { - return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); - function getDevice(string $root, string $connection = ''): Device { $connection = ! empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', ''); @@ -1325,33 +293,17 @@ function getDevice(string $root, string $connection = ''): Device } } -Http::setResource('mode', function (Request $request, Document $project) { - /** - * Defines the mode for the request: - * - 'default' => Requests for Client and Server Side - * - 'admin' => Request from the Console on non-console projects - */ - $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT)); - - $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', '')); - if (!empty($projectId) && $project->getId() !== $projectId) { - $mode = APP_MODE_ADMIN; - } - - return $mode; -}, ['request', 'project']); - -Http::setResource('geodb', function ($register) { +$container->set('geodb', function ($register) { /** @var Utopia\Registry\Registry $register */ return $register->get('geodb'); }, ['register']); -Http::setResource('passwordsDictionary', function ($register) { +$container->set('passwordsDictionary', function ($register) { /** @var Utopia\Registry\Registry $register */ return $register->get('passwordsDictionary'); }, ['register']); -Http::setResource('servers', function () { +$container->set('servers', function () { $platforms = Config::getParam('sdks'); $server = $platforms[APP_SDK_PLATFORM_SERVER]; @@ -1362,358 +314,25 @@ Http::setResource('servers', function () { return $languages; }); -Http::setResource('promiseAdapter', function ($register) { +$container->set('promiseAdapter', function ($register) { return $register->get('promiseAdapter'); }, ['register']); -Http::setResource('schema', function ($utopia, $dbForProject, $authorization) { - - $complexity = function (int $complexity, array $args) { - $queries = Query::parseQueries($args['queries'] ?? []); - $query = Query::getByType($queries, [Query::TYPE_LIMIT])[0] ?? null; - $limit = $query ? $query->getValue() : APP_LIMIT_LIST_DEFAULT; - - return $complexity * $limit; - }; - - $attributes = function (int $limit, int $offset) use ($dbForProject, $authorization) { - $attrs = $authorization->skip(fn () => $dbForProject->find('attributes', [ - Query::limit($limit), - Query::offset($offset), - ])); - - return \array_map(function ($attr) { - return $attr->getArrayCopy(); - }, $attrs); - }; - - $urls = [ - 'list' => function (string $databaseId, string $collectionId, array $args) { - return "/v1/databases/$databaseId/collections/$collectionId/documents"; - }, - 'create' => function (string $databaseId, string $collectionId, array $args) { - return "/v1/databases/$databaseId/collections/$collectionId/documents"; - }, - 'read' => function (string $databaseId, string $collectionId, array $args) { - return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}"; - }, - 'update' => function (string $databaseId, string $collectionId, array $args) { - return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}"; - }, - 'delete' => function (string $databaseId, string $collectionId, array $args) { - return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}"; - }, - ]; - - // NOTE: `params` and `urls` are not used internally in the `Schema::build` function below! - $params = [ - 'list' => function (string $databaseId, string $collectionId, array $args) { - return ['queries' => $args['queries']]; - }, - 'create' => function (string $databaseId, string $collectionId, array $args) { - $id = $args['id'] ?? 'unique()'; - $permissions = $args['permissions'] ?? null; - - unset($args['id']); - unset($args['permissions']); - - // Order must be the same as the route params - return [ - 'databaseId' => $databaseId, - 'documentId' => $id, - 'collectionId' => $collectionId, - 'data' => $args, - 'permissions' => $permissions, - ]; - }, - 'update' => function (string $databaseId, string $collectionId, array $args) { - $documentId = $args['id']; - $permissions = $args['permissions'] ?? null; - - unset($args['id']); - unset($args['permissions']); - - // Order must be the same as the route params - return [ - 'databaseId' => $databaseId, - 'collectionId' => $collectionId, - 'documentId' => $documentId, - 'data' => $args, - 'permissions' => $permissions, - ]; - }, - ]; - - return Schema::build( - $utopia, - $complexity, - $attributes, - $urls, - $params, - ); -}, ['utopia', 'dbForProject', 'authorization']); - -Http::setResource('gitHub', function (Cache $cache) { +$container->set('gitHub', function (Cache $cache) { return new VcsGitHub($cache); }, ['cache']); -Http::setResource('requestTimestamp', function ($request) { - // TODO: Move this to the Request class itself - $timestampHeader = $request->getHeader('x-appwrite-timestamp'); - $requestTimestamp = null; - if (! empty($timestampHeader)) { - try { - $requestTimestamp = new \DateTime($timestampHeader); - } catch (\Throwable $e) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid X-Appwrite-Timestamp header value'); - } - } - - return $requestTimestamp; -}, ['request']); - -Http::setResource('plan', function (array $plan = []) { +$container->set('plan', function () { return []; }); -Http::setResource('smsRates', function () { +$container->set('smsRates', function () { return []; }); -Http::setResource('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) { - $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', '')); - - // Check if given key match project's development keys - $key = $project->find('secret', $devKey, 'devKeys'); - if (! $key) { - return new Document([]); - } - - // check expiration - $expire = $key->getAttribute('expire'); - if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { - return new Document([]); - } - - // update access time - $accessedAt = $key->getAttribute('accessedAt', 0); - if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) { - $key->setAttribute('accessedAt', DatabaseDateTime::now()); - $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ - 'accessedAt' => $key->getAttribute('accessedAt') - ]))); - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - } - - // add sdk to key - $sdkValidator = new WhiteList($servers, true); - $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { - $sdks = $key->getAttribute('sdks', []); - - if (! in_array($sdk, $sdks)) { - $sdks[] = $sdk; - $key->setAttribute('sdks', $sdks); - - /** Update access time as well */ - $key->setAttribute('accessedAt', DatabaseDateTime::now()); - $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ - 'sdks' => $key->getAttribute('sdks'), - 'accessedAt' => $key->getAttribute('accessedAt') - ]))); - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - } - } - - return $key; -}, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']); - -Http::setResource('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) { - $teamInternalId = ''; - if ($project->getId() !== 'console') { - $teamInternalId = $project->getAttribute('teamInternalId', ''); - } else { - $route = $utopia->match($request); - $path = ! empty($route) ? $route->getPath() : $request->getURI(); - $orgHeader = $request->getHeader('x-appwrite-organization', ''); - if (str_starts_with($path, '/v1/projects/:projectId')) { - $uri = $request->getURI(); - $pid = explode('/', $uri)[3]; - $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid)); - $teamInternalId = $p->getAttribute('teamInternalId', ''); - } elseif ($path === '/v1/projects') { - $teamId = $request->getParam('teamId', ''); - - if (empty($teamId)) { - return new Document([]); - } - - $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId)); - - return $team; - } elseif (! empty($orgHeader)) { - return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); - } - } - - // if teamInternalId is empty, return an empty document - - if (empty($teamInternalId)) { - return new Document([]); - } - - $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) { - return $dbForPlatform->findOne('teams', [ - Query::equal('$sequence', [$teamInternalId]), - ]); - }); - - return $team; -}, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']); - -Http::setResource( +$container->set( 'isResourceBlocked', fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false ); -Http::setResource('previewHostname', function (Request $request, ?Key $apiKey) { - $allowed = false; - - if (Http::isDevelopment()) { - $allowed = true; - } elseif (! \is_null($apiKey) && $apiKey->getHostnameOverride() === true) { - $allowed = true; - } - - if ($allowed) { - $host = $request->getQuery('appwrite-hostname', $request->getHeader('x-appwrite-hostname', '')) ?? ''; - if (! empty($host)) { - return $host; - } - } - - return ''; -}, ['request', 'apiKey']); - -Http::setResource('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key { - $key = $request->getHeader('x-appwrite-key'); - - if (empty($key)) { - return null; - } - - $key = Key::decode($project, $team, $user, $key); - - $userHeader = $request->getHeader('x-appwrite-user'); - $organizationHeader = $request->getHeader('x-appwrite-organization'); - $projectHeader = $request->getHeader('x-appwrite-project'); - - if (! empty($key->getProjectId())) { - if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) { - throw new Exception(Exception::PROJECT_ID_MISSING); - } - } - - if (! empty($key->getUserId())) { - if (empty($userHeader) || $userHeader !== $key->getUserId()) { - throw new Exception(Exception::USER_ID_MISSING); - } - } - - if (! empty($key->getTeamId())) { - if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) { - throw new Exception(Exception::ORGANIZATION_ID_MISSING); - } - } - - return $key; -}, ['request', 'project', 'team', 'user']); - -Http::setResource('executor', fn () => new Executor()); - -Http::setResource('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) { - $tokenJWT = $request->getParam('token'); - - if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication - // Use a large but reasonable maxAge to avoid auto-exp when token has no expiry - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), RESOURCE_TOKEN_ALGORITHM, RESOURCE_TOKEN_MAX_AGE, RESOURCE_TOKEN_LEEWAY); // Instantiate with key, algo, maxAge and leeway. - - try { - $payload = $jwt->decode($tokenJWT); - } catch (JWTException $error) { - return new Document([]); - } - - $tokenId = $payload['tokenId'] ?? ''; - if (empty($tokenId)) { - return new Document([]); - } - - $token = $authorization->skip(fn () => $dbForProject->getDocument('resourceTokens', $tokenId)); - - if ($token->isEmpty()) { - return new Document([]); - } - - $expiry = $token->getAttribute('expire'); - - if ($expiry !== null) { - $now = new \DateTime(); - $expiryDate = new \DateTime($expiry); - - if ($expiryDate < $now) { - return new Document([]); - } - } - - return match ($token->getAttribute('resourceType')) { - TOKENS_RESOURCE_TYPE_FILES => (function () use ($token, $dbForProject, $authorization) { - $sequences = explode(':', $token->getAttribute('resourceInternalId')); - $ids = explode(':', $token->getAttribute('resourceId')); - - if (count($sequences) !== 2 || count($ids) !== 2) { - return new Document([]); - } - - $accessedAt = $token->getAttribute('accessedAt', 0); - if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) { - $token->setAttribute('accessedAt', DatabaseDateTime::now()); - $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([ - 'accessedAt' => $token->getAttribute('accessedAt') - ]))); - } - - return new Document([ - 'bucketId' => $ids[0], - 'fileId' => $ids[1], - 'bucketInternalId' => $sequences[0], - 'fileInternalId' => $sequences[1], - ]); - })(), - - default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID), - }; - } - - return new Document([]); -}, ['project', 'dbForProject', 'request', 'authorization']); - -Http::setResource('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) { - return new TransactionState($dbForProject, $authorization, $getDatabasesDB); -}, ['dbForProject', 'authorization', 'getDatabasesDB']); - -Http::setResource('executionsRetentionCount', function (Document $project, array $plan) { - if ($project->getId() === 'console' || empty($plan)) { - return 0; - } - - return (int) ($plan['executionsRetentionCount'] ?? 100); -}, ['project', 'plan']); - -Http::setResource('embeddingAgent', function ($register) { - $adapter = new Ollama(); - $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed')); - $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000')); - return new Agent($adapter); -}, ['register']); +$container->set('executor', fn () => new Executor()); diff --git a/app/init/resources/request.php b/app/init/resources/request.php new file mode 100644 index 0000000000..156e151501 --- /dev/null +++ b/app/init/resources/request.php @@ -0,0 +1,1477 @@ +set('utopia:graphql', function ($utopia) { + return $utopia; + }, ['utopia']); + + $container->set('log', fn () => new Log(), []); + + $container->set('logger', function ($register) { + return $register->get('logger'); + }, ['register']); + + $container->set('authorization', function () { + return new Authorization(); + }, []); + + $container->set('store', function (): Store { + return new Store(); + }, []); + + $container->set('proofForPassword', function (): Password { + $hash = new Argon2(); + $hash + ->setMemoryCost(7168) + ->setTimeCost(5) + ->setThreads(1); + + $password = new Password(); + $password + ->setHash($hash); + + return $password; + }); + + $container->set('proofForToken', function (): Token { + $token = new Token(); + $token->setHash(new Sha()); + + return $token; + }); + + $container->set('proofForCode', function (): Code { + $code = new Code(); + $code->setHash(new Sha()); + + return $code; + }); + + $container->set('locale', function () { + $locale = new Locale(System::getEnv('_APP_LOCALE', 'en')); + $locale->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + return $locale; + }); + + // Per-request queue resources (stateful, accumulate event data during request) + $container->set('queueForMessaging', function (Publisher $publisher) { + return new Messaging($publisher); + }, ['publisher']); + $container->set('queueForMails', function (Publisher $publisher) { + return new Mail($publisher); + }, ['publisher']); + $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']); + $container->set('queueForDeletes', function (Publisher $publisher) { + return new Delete($publisher); + }, ['publisher']); + $container->set('queueForEvents', function (Publisher $publisher) { + return new Event($publisher); + }, ['publisher']); + $container->set('queueForWebhooks', function (Publisher $publisher) { + return new Webhook($publisher); + }, ['publisher']); + $container->set('queueForRealtime', function () { + return new Realtime(); + }, []); + $container->set('usage', function () { + return new UsageContext(); + }, []); + $container->set('queueForAudits', function (Publisher $publisher) { + return new AuditEvent($publisher); + }, ['publisher']); + $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); + }, ['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')); + $database = new Database($adapter, $cache); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setNamespace('_console') + ->setMetadata('host', \gethostname()) + ->setMetadata('project', 'console') + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + + $database->setDocumentType('users', User::class); + + return $database; + }, ['pools', 'cache', 'authorization']); + + $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) { + $adapters = []; + + return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$adapters) { + if ($project->isEmpty() || $project->getId() === 'console') { + return $dbForPlatform; + } + + $database = $project->getAttribute('database', ''); + if (empty($database)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured'); + } + + try { + $dsn = new DSN($database); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $database); + } + + $adapter = $adapters[$dsn->getHost()] ??= new DatabasePool($pools->get($dsn->getHost())); + $database = new Database($adapter, $cache); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setMetadata('host', \gethostname()) + ->setMetadata('project', $project->getId()) + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES) + ->setDocumentType('users', User::class); + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + + return $database; + }; + }, ['pools', 'dbForPlatform', 'cache', 'authorization']); + + $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { + $adapter = null; + + return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) { + $adapter ??= new DatabasePool($pools->get('logs')); + $database = new Database($adapter, $cache); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setSharedTables(true) + ->setNamespace('logsV1') + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + + if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); + } + + return $database; + }; + }, ['pools', 'cache', 'authorization']); + + /** + * List of allowed request hostnames for the request. + */ + $container->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) { + $allowed = [...($platform['hostnames'] ?? [])]; + + /* Add platform configured hostnames */ + if (! $project->isEmpty() && $project->getId() !== 'console') { + $platforms = $project->getAttribute('platforms', []); + $hostnames = Platform::getHostnames($platforms); + $allowed = [...$allowed, ...$hostnames]; + } + + /* Add the request hostname if a dev key is found */ + if (! $devKey->isEmpty()) { + $allowed[] = $request->getHostname(); + } + + $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST); + $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST); + + $hostname = $originHostname; + if (empty($hostname)) { + $hostname = $refererHostname; + } + + /* Add request hostname for preflight requests */ + if ($request->getMethod() === 'OPTIONS') { + $allowed[] = $hostname; + } + + /* Allow the request origin of rule */ + if (! $rule->isEmpty() && ! empty($rule->getAttribute('domain', ''))) { + $allowed[] = $rule->getAttribute('domain', ''); + } + + /* Allow the request origin if a dev key is found */ + if (! $devKey->isEmpty() && ! empty($hostname)) { + $allowed[] = $hostname; + } + + return array_unique($allowed); + }, ['platform', 'project', 'rule', 'devKey', 'request']); + + /** + * List of allowed request schemes for the request. + */ + $container->set('allowedSchemes', function (array $platform, Document $project) { + $allowed = [...($platform['schemas'] ?? [])]; + + if (! $project->isEmpty() && $project->getId() !== 'console') { + /* Add hardcoded schemes */ + $allowed[] = 'exp'; + $allowed[] = 'appwrite-callback-' . $project->getId(); + + /* Add platform configured schemes */ + $platforms = $project->getAttribute('platforms', []); + $schemes = Platform::getSchemes($platforms); + $allowed = [...$allowed, ...$schemes]; + } + + return array_unique($allowed); + }, ['platform', 'project']); + + /** + * Whether the request origin is verified against the request hostname. + */ + $container->set('domainVerification', function (Request $request) { + $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST); + $selfDomain = new Domain($request->getHostname()); + $endDomain = new Domain((string) $origin); + + return ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) + && $endDomain->getRegisterable() !== ''; + }, ['request']); + + /** + * Cookie domain for the current request. + */ + $container->set('cookieDomain', function (Request $request, Document $project) { + $localHosts = ['localhost', 'localhost:' . $request->getPort()]; + + $migrationHost = System::getEnv('_APP_MIGRATION_HOST'); + if (!empty($migrationHost)) { + // Treat the migration host like localhost because internal migration and CI + // traffic may use it before a public domain is configured. + $localHosts[] = $migrationHost; + $localHosts[] = $migrationHost . ':' . $request->getPort(); + } + + $hostname = $request->getHostname(); + $isLocalHost = \in_array($hostname, $localHosts, true); + $isIpAddress = \filter_var($hostname, FILTER_VALIDATE_IP) !== false; + + if ($isLocalHost || $isIpAddress) { + return; + } + + $isConsoleProject = $project->getAttribute('$id', '') === 'console'; + $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled'; + + if ($isConsoleProject && $isConsoleRootSession) { + $domain = new Domain($hostname); + + return '.' . $domain->getRegisterable(); + } + + return '.' . $hostname; + }, ['request', 'project']); + + /** + * Rule associated with a request origin. + */ + $container->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) { + $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); + + if (empty($domain)) { + $domain = \parse_url($request->getReferer(), PHP_URL_HOST); + } + + if (empty($domain)) { + return new Document(); + } + + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); + } + + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); + + $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + + // Temporary implementation until custom wildcard domains are an official feature + // Allow trusted projects; Used for Console (website) previews + if (! $permitsCurrentProject && ! $rule->isEmpty() && ! empty($rule->getAttribute('projectId', ''))) { + $trustedProjects = []; + foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { + if (empty($trustedProject)) { + continue; + } + $trustedProjects[] = $trustedProject; + } + if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { + $permitsCurrentProject = true; + } + } + + if (! $permitsCurrentProject) { + return new Document(); + } + + return $rule; + }, ['request', 'dbForPlatform', 'project', 'authorization']); + + /** + * CORS service + */ + $container->set('cors', function (array $allowedHostnames) { + $corsConfig = Config::getParam('cors'); + + return new Cors( + $allowedHostnames, + allowedMethods: $corsConfig['allowedMethods'], + allowedHeaders: $corsConfig['allowedHeaders'], + allowCredentials: true, + exposedHeaders: $corsConfig['exposedHeaders'], + ); + }, ['allowedHostnames']); + + $container->set('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { + if (! $devKey->isEmpty()) { + return new URL(); + } + + return new Origin($allowedHostnames, $allowedSchemes); + }, ['devKey', 'allowedHostnames', 'allowedSchemes']); + + $container->set('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { + if (! $devKey->isEmpty()) { + return new URL(); + } + + return new Redirect($allowedHostnames, $allowedSchemes); + }, ['devKey', 'allowedHostnames', 'allowedSchemes']); + + $container->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) { + /** + * Handles 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: + * 1. Checks the cookie based on mode: + * - If in admin mode, uses console project id for key. + * - Otherwise, sets the key using the project ID + * 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`. + * - If this method is used, returns the header: `X-Debug-Fallback: true`. + * 3. Fetches the user document from the appropriate database based on the mode. + * 4. If the user document is empty or the session key cannot be verified, sets an empty user document. + * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token. + * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`, + * overwriting the previous value. + * 7. If account API key is passed, use user of the account API key as long as user ID header matches too + */ + $authorization->setDefaultStatus(true); + + $store->setKey('a_session_' . $project->getId()); + + if ($mode === APP_MODE_ADMIN) { + $store->setKey('a_session_' . $console->getId()); + } + + $store->decode( + $request->getCookie( + $store->getKey(), // Get sessions + $request->getCookie($store->getKey() . '_legacy', '') + ) + ); + + // Get session from header for SSR clients + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + $sessionHeader = $request->getHeader('x-appwrite-session', ''); + + if (! empty($sessionHeader)) { + $store->decode($sessionHeader); + } + } + + // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies + if ($response) { // if in http context - add debug header + $response->addHeader('X-Debug-Fallback', 'false'); + } + + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + if ($response) { + $response->addHeader('X-Debug-Fallback', 'true'); + } + $fallback = $request->getHeader('x-fallback-cookies', ''); + $fallback = \json_decode($fallback, true); + $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); + } + + $user = null; + if ($mode === APP_MODE_ADMIN) { + /** @var User $user */ + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + if ($project->isEmpty()) { + $user = new User([]); + } else { + if (! empty($store->getProperty('id', ''))) { + if ($project->getId() === 'console') { + /** @var User $user */ + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + /** @var User $user */ + $user = $dbForProject->getDocument('users', $store->getProperty('id', '')); + } + } + } + } + + if ( + ! $user || + $user->isEmpty() // Check a document has been found in the DB + || ! $user->sessionVerify($store->getProperty('secret', ''), $proofForToken) + ) { // Validate user has valid login token + $user = new User([]); + } + + $authJWT = $request->getHeader('x-appwrite-jwt', ''); + if (! empty($authJWT) && ! $project->isEmpty()) { // JWT authentication + if (! $user->isEmpty()) { + throw new Exception(Exception::USER_JWT_AND_COOKIE_SET); + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); + try { + $payload = $jwt->decode($authJWT); + } catch (JWTException $error) { + throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); + } + + $jwtUserId = $payload['userId'] ?? ''; + if (! empty($jwtUserId)) { + if ($mode === APP_MODE_ADMIN) { + $user = $dbForPlatform->getDocument('users', $jwtUserId); + } else { + $user = $dbForProject->getDocument('users', $jwtUserId); + } + } + $jwtSessionId = $payload['sessionId'] ?? ''; + if (! empty($jwtSessionId)) { + if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token + $user = new User([]); + } + } + } + + // Account based on account API key + $accountKey = $request->getHeader('x-appwrite-key', ''); + $accountKeyUserId = $request->getHeader('x-appwrite-user', ''); + if (! empty($accountKeyUserId) && ! empty($accountKey)) { + if (! $user->isEmpty()) { + throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); + } + + $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); + if (! $accountKeyUser->isEmpty()) { + $key = $accountKeyUser->find( + key: 'secret', + find: $accountKey, + subject: 'keys' + ); + + if (! empty($key)) { + $expire = $key->getAttribute('expire'); + if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + throw new Exception(Exception::ACCOUNT_KEY_EXPIRED); + } + + $user = $accountKeyUser; + } + } + } + + // Impersonation: if current user has impersonator capability and headers are set, act as another user + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { + $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; + $targetUser = null; + if (!empty($impersonateUserId)) { + $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->getDocument('users', $impersonateUserId)); + } elseif (!empty($impersonateEmail)) { + $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('email', [\strtolower($impersonateEmail)])])); + } elseif (!empty($impersonatePhone)) { + $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('phone', [$impersonatePhone])])); + } + if ($targetUser !== null && !$targetUser->isEmpty()) { + $impersonator = clone $user; + $user = clone $targetUser; + $user->setAttribute('impersonatorUserId', $impersonator->getId()); + $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence()); + $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', '')); + $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', '')); + $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0)); + } + } + + $dbForProject->setMetadata('user', $user->getId()); + $dbForPlatform->setMetadata('user', $user->getId()); + + return $user; + }, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']); + + $container->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) { + /** @var Appwrite\Utopia\Request $request */ + /** @var Utopia\Database\Database $dbForPlatform */ + /** @var Utopia\Database\Document $console */ + $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', '')); + // Realtime channel "project" can send project=Query array + if (! \is_string($projectId)) { + $projectId = $request->getHeader('x-appwrite-project', ''); + } + + // Backwards compatibility for new services, originally project resources + // These endpoints moved from /v1/projects/:projectId/ to /v1/ + // When accessed via the old alias path, extract projectId from the URI + $deprecatedProjectPathPrefix = '/v1/projects/'; + $route = $utopia->match($request); + if (!empty($route)) { + $isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) && + !\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix); + + if ($isDeprecatedAlias) { + $projectId = \explode('/', $request->getURI(), 5)[3] ?? ''; + } + } + + if (empty($projectId) || $projectId === 'console') { + return $console; + } + + $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + + return $project; + }, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']); + + $container->set('session', function (User $user, Store $store, Token $proofForToken) { + if ($user->isEmpty()) { + return; + } + + $sessions = $user->getAttribute('sessions', []); + $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); + + if (! $sessionId) { + return; + } + foreach ($sessions as $session) { + /** @var Document $session */ + if ($sessionId === $session->getId()) { + return $session; + } + } + + return; + }, ['user', 'store', 'proofForToken']); + + $container->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) { + if ($project->isEmpty() || $project->getId() === 'console') { + return $dbForPlatform; + } + + $database = $project->getAttribute('database', ''); + if (empty($database)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured'); + } + + try { + $dsn = new DSN($database); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $database); + } + + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $database = new Database($adapter, $cache); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setMetadata('host', \gethostname()) + ->setMetadata('project', $project->getId()) + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + $database->setDocumentType('users', User::class); + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + + /** + * This isolated event handling for `users.*.create` which is based on a `Database::EVENT_DOCUMENT_CREATE` listener may look odd, but it is **intentional**. + * + * Accounts can be created in many ways beyond `createAccount` + * (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here. + */ + $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) { + // Only trigger events for user creation with the database listener. + if ($document->getCollection() !== 'users') { + return; + } + + $queueForEvents + ->setEvent('users.[userId].create') + ->setParam('userId', $document->getId()) + ->setPayload($response->output($document, Response::MODEL_USER)); + + // Trigger functions, webhooks, and realtime events + $queueForFunctions + ->from($queueForEvents) + ->trigger(); + + /** Trigger webhooks events only if a project has them enabled */ + if (! empty($project->getAttribute('webhooks'))) { + $queueForWebhooks + ->from($queueForEvents) + ->trigger(); + } + + /** Trigger realtime events only for non console events */ + if ($queueForEvents->getProject()->getId() !== 'console') { + $queueForRealtime + ->from($queueForEvents) + ->trigger(); + } + }; + + /** + * Purge function events cache when functions are created, updated or deleted. + */ + $functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) { + + if ($document->getCollection() !== 'functions') { + return; + } + + if ($project->isEmpty() || $project->getId() === 'console') { + return; + } + + $hostname = $dbForProject->getAdapter()->getHostname(); + $cacheKey = \sprintf( + '%s-cache-%s:%s:%s:project:%s:functions:events', + $dbForProject->getCacheName(), + $hostname, + $dbForProject->getNamespace(), + $dbForProject->getTenant(), + $project->getId() + ); + + $dbForProject->getCache()->purge($cacheKey); + }; + + /** + * Prefix metrics with database type when applicable. + * Avoids prefixing for legacy and tablesdb types to preserve historical metrics. + */ + $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string { + if ( + $databaseType === '' || + $databaseType === DATABASE_TYPE_LEGACY || + $databaseType === DATABASE_TYPE_TABLESDB + ) { + return $metric; + } + + return $databaseType . '.' . $metric; + }; + + // Determine database type from request path, similar to api.php + $path = $request->getURI(); + $databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => '', + }; + + $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) use ($getDatabaseTypePrefixedMetric, $databaseType) { + $value = 1; + + switch ($event) { + case Database::EVENT_DOCUMENT_DELETE: + $value = -1; + break; + case Database::EVENT_DOCUMENTS_DELETE: + $value = -1 * $document->getAttribute('modified', 0); + break; + case Database::EVENT_DOCUMENTS_CREATE: + $value = $document->getAttribute('modified', 0); + break; + case Database::EVENT_DOCUMENTS_UPSERT: + $value = $document->getAttribute('created', 0); + break; + } + + switch (true) { + case $document->getCollection() === 'teams': + $usage->addMetric(METRIC_TEAMS, $value); // per project + break; + case $document->getCollection() === 'users': + $usage->addMetric(METRIC_USERS, $value); // per project + if ($event === Database::EVENT_DOCUMENT_DELETE) { + $usage->addReduce($document); + } + break; + case $document->getCollection() === 'sessions': // sessions + $usage->addMetric(METRIC_SESSIONS, $value); // per project + break; + case $document->getCollection() === 'databases': // databases + $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES); + $usage->addMetric($metric, $value); // per project + + if ($event === Database::EVENT_DOCUMENT_DELETE) { + $usage->addReduce($document); + } + break; + case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS); + $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS); + $usage + ->addMetric($collectionMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value); + + if ($event === Database::EVENT_DOCUMENT_DELETE) { + $usage->addReduce($document); + } + break; + case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS); + $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS); + $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS); + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + break; + case $document->getCollection() === 'buckets': // buckets + $usage->addMetric(METRIC_BUCKETS, $value); // per project + if ($event === Database::EVENT_DOCUMENT_DELETE) { + $usage + ->addReduce($document); + } + break; + case str_starts_with($document->getCollection(), 'bucket_'): // files + $parts = explode('_', $document->getCollection()); + $bucketInternalId = $parts[1]; + $usage + ->addMetric(METRIC_FILES, $value) // per project + ->addMetric(METRIC_FILES_STORAGE, $document->getAttribute('sizeOriginal') * $value) // per project + ->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES), $value) // per bucket + ->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES_STORAGE), $document->getAttribute('sizeOriginal') * $value); // per bucket + break; + case $document->getCollection() === 'functions': + $usage->addMetric(METRIC_FUNCTIONS, $value); // per project + + if ($event === Database::EVENT_DOCUMENT_DELETE) { + $usage + ->addReduce($document); + } + break; + case $document->getCollection() === 'sites': + $usage->addMetric(METRIC_SITES, $value); // per project + + if ($event === Database::EVENT_DOCUMENT_DELETE) { + $usage + ->addReduce($document); + } + break; + case $document->getCollection() === 'deployments': + $usage + ->addMetric(METRIC_DEPLOYMENTS, $value) // per project + ->addMetric(METRIC_DEPLOYMENTS_STORAGE, $document->getAttribute('size') * $value) // per project + ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS), $value) // per function + ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value) + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS), $value) // per function + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value); + break; + default: + break; + } + }; + + // Clone the queues, to prevent events triggered by the database listener + // from overwriting the events that are supposed to be triggered in the shutdown hook. + $queueForEventsClone = new Event($publisher); + $queueForFunctions = new Func($publisherFunctions); + $queueForWebhooks = new Webhook($publisherWebhooks); + $queueForRealtime = new Realtime(); + + $database + ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener( + $project, + $document, + $response, + $queueForEventsClone->from($queueForEvents), + $queueForFunctions->from($queueForEvents), + $queueForWebhooks->from($queueForEvents), + $queueForRealtime->from($queueForEvents) + )) + ->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) + ->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) + ->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)); + + return $database; + }, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']); + + $container->set('schema', function ($utopia, $dbForProject, $authorization) { + + $complexity = function (int $complexity, array $args) { + $queries = Query::parseQueries($args['queries'] ?? []); + $query = Query::getByType($queries, [Query::TYPE_LIMIT])[0] ?? null; + $limit = $query ? $query->getValue() : APP_LIMIT_LIST_DEFAULT; + + return $complexity * $limit; + }; + + $attributes = function (int $limit, int $offset) use ($dbForProject, $authorization) { + $attrs = $authorization->skip(fn () => $dbForProject->find('attributes', [ + Query::limit($limit), + Query::offset($offset), + ])); + + return \array_map(function ($attr) { + return $attr->getArrayCopy(); + }, $attrs); + }; + + $urls = [ + 'list' => function (string $databaseId, string $collectionId, array $args) { + return "/v1/databases/$databaseId/collections/$collectionId/documents"; + }, + 'create' => function (string $databaseId, string $collectionId, array $args) { + return "/v1/databases/$databaseId/collections/$collectionId/documents"; + }, + 'read' => function (string $databaseId, string $collectionId, array $args) { + return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}"; + }, + 'update' => function (string $databaseId, string $collectionId, array $args) { + return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}"; + }, + 'delete' => function (string $databaseId, string $collectionId, array $args) { + return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}"; + }, + ]; + + // NOTE: `params` and `urls` are not used internally in the `Schema::build` function below! + $params = [ + 'list' => function (string $databaseId, string $collectionId, array $args) { + return ['queries' => $args['queries']]; + }, + 'create' => function (string $databaseId, string $collectionId, array $args) { + $id = $args['id'] ?? 'unique()'; + $permissions = $args['permissions'] ?? null; + + unset($args['id']); + unset($args['permissions']); + + // Order must be the same as the route params + return [ + 'databaseId' => $databaseId, + 'documentId' => $id, + 'collectionId' => $collectionId, + 'data' => $args, + 'permissions' => $permissions, + ]; + }, + 'update' => function (string $databaseId, string $collectionId, array $args) { + $documentId = $args['id']; + $permissions = $args['permissions'] ?? null; + + unset($args['id']); + unset($args['permissions']); + + // Order must be the same as the route params + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => $documentId, + 'data' => $args, + 'permissions' => $permissions, + ]; + }, + ]; + + return Schema::build( + $utopia, + $complexity, + $attributes, + $urls, + $params, + ); + }, ['utopia', 'dbForProject', 'authorization']); + + $container->set('audit', function ($dbForProject) { + $adapter = new AdapterDatabase($dbForProject); + + return new Audit($adapter); + }, ['dbForProject']); + + $container->set('mode', function ($request, Document $project) { + /** @var Appwrite\Utopia\Request $request */ + + /** + * Defines the mode for the request: + * - 'default' => Requests for Client and Server Side + * - 'admin' => Request from the Console on non-console projects + */ + $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT)); + + $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', '')); + if (!empty($projectId) && $project->getId() !== $projectId) { + $mode = APP_MODE_ADMIN; + } + + return $mode; + }, ['request', 'project']); + + $container->set('requestTimestamp', function ($request) { + // TODO: Move this to the Request class itself + $timestampHeader = $request->getHeader('x-appwrite-timestamp'); + $requestTimestamp = null; + if (! empty($timestampHeader)) { + try { + $requestTimestamp = new \DateTime($timestampHeader); + } catch (\Throwable $e) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid X-Appwrite-Timestamp header value'); + } + } + + return $requestTimestamp; + }, ['request']); + + $container->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) { + $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', '')); + + // Check if given key match project's development keys + $key = $project->find('secret', $devKey, 'devKeys'); + if (! $key) { + return new Document([]); + } + + // check expiration + $expire = $key->getAttribute('expire'); + if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + return new Document([]); + } + + // update access time + $accessedAt = $key->getAttribute('accessedAt', 0); + if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) { + $key->setAttribute('accessedAt', DatabaseDateTime::now()); + $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'accessedAt' => $key->getAttribute('accessedAt') + ]))); + $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + } + + // add sdk to key + $sdkValidator = new WhiteList($servers, true); + $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); + + if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + $sdks = $key->getAttribute('sdks', []); + + if (! in_array($sdk, $sdks)) { + $sdks[] = $sdk; + $key->setAttribute('sdks', $sdks); + + /** Update access time as well */ + $key->setAttribute('accessedAt', DatabaseDateTime::now()); + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'sdks' => $key->getAttribute('sdks'), + 'accessedAt' => $key->getAttribute('accessedAt') + ]))); + $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + } + } + + return $key; + }, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']); + + $container->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) { + $teamInternalId = ''; + if ($project->getId() !== 'console') { + $teamInternalId = $project->getAttribute('teamInternalId', ''); + } else { + $route = $utopia->match($request); + $path = ! empty($route) ? $route->getPath() : $request->getURI(); + $orgHeader = $request->getHeader('x-appwrite-organization', ''); + if (str_starts_with($path, '/v1/projects/:projectId')) { + $uri = $request->getURI(); + $pid = explode('/', $uri)[3]; + $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid)); + $teamInternalId = $p->getAttribute('teamInternalId', ''); + } elseif ($path === '/v1/projects') { + $teamId = $request->getParam('teamId', ''); + + if (empty($teamId)) { + return new Document([]); + } + + $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId)); + + return $team; + } elseif (! empty($orgHeader)) { + return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); + } + } + + // if teamInternalId is empty, return an empty document + + if (empty($teamInternalId)) { + return new Document([]); + } + + $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) { + return $dbForPlatform->findOne('teams', [ + Query::equal('$sequence', [$teamInternalId]), + ]); + }); + + return $team; + }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']); + + $container->set('previewHostname', function (Request $request, ?Key $apiKey) { + $allowed = false; + + if (Http::isDevelopment()) { + $allowed = true; + } elseif (! \is_null($apiKey) && $apiKey->getHostnameOverride() === true) { + $allowed = true; + } + + if ($allowed) { + $host = $request->getQuery('appwrite-hostname', $request->getHeader('x-appwrite-hostname', '')) ?? ''; + if (! empty($host)) { + return $host; + } + } + + return ''; + }, ['request', 'apiKey']); + + $container->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key { + $key = $request->getHeader('x-appwrite-key'); + + if (empty($key)) { + return null; + } + + $key = Key::decode($project, $team, $user, $key); + + $userHeader = $request->getHeader('x-appwrite-user'); + $organizationHeader = $request->getHeader('x-appwrite-organization'); + $projectHeader = $request->getHeader('x-appwrite-project'); + + if (! empty($key->getProjectId())) { + if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) { + throw new Exception(Exception::PROJECT_ID_MISSING); + } + } + + if (! empty($key->getUserId())) { + if (empty($userHeader) || $userHeader !== $key->getUserId()) { + throw new Exception(Exception::USER_ID_MISSING); + } + } + + if (! empty($key->getTeamId())) { + if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) { + throw new Exception(Exception::ORGANIZATION_ID_MISSING); + } + } + + return $key; + }, ['request', 'project', 'team', 'user']); + + $container->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) { + $tokenJWT = $request->getParam('token'); + + if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication + // Use a large but reasonable maxAge to avoid auto-exp when token has no expiry + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), RESOURCE_TOKEN_ALGORITHM, RESOURCE_TOKEN_MAX_AGE, RESOURCE_TOKEN_LEEWAY); // Instantiate with key, algo, maxAge and leeway. + + try { + $payload = $jwt->decode($tokenJWT); + } catch (JWTException $error) { + return new Document([]); + } + + $tokenId = $payload['tokenId'] ?? ''; + if (empty($tokenId)) { + return new Document([]); + } + + $token = $authorization->skip(fn () => $dbForProject->getDocument('resourceTokens', $tokenId)); + + if ($token->isEmpty()) { + return new Document([]); + } + + $expiry = $token->getAttribute('expire'); + + if ($expiry !== null) { + $now = new \DateTime(); + $expiryDate = new \DateTime($expiry); + + if ($expiryDate < $now) { + return new Document([]); + } + } + + return match ($token->getAttribute('resourceType')) { + TOKENS_RESOURCE_TYPE_FILES => (function () use ($token, $dbForProject, $authorization) { + $sequences = explode(':', $token->getAttribute('resourceInternalId')); + $ids = explode(':', $token->getAttribute('resourceId')); + + if (count($sequences) !== 2 || count($ids) !== 2) { + return new Document([]); + } + + $accessedAt = $token->getAttribute('accessedAt', 0); + if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) { + $token->setAttribute('accessedAt', DatabaseDateTime::now()); + $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([ + 'accessedAt' => $token->getAttribute('accessedAt') + ]))); + } + + return new Document([ + 'bucketId' => $ids[0], + 'fileId' => $ids[1], + 'bucketInternalId' => $sequences[0], + 'fileInternalId' => $sequences[1], + ]); + })(), + + default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID), + }; + } + + return new Document([]); + }, ['project', 'dbForProject', 'request', 'authorization']); + + $container->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) { + + return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database { + $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', '')); + $databaseType = $database->getAttribute('type', ''); + + try { + $databaseDSN = new DSN($databaseDSN); + } catch (\InvalidArgumentException) { + // for old databases migrated through patch script + // databaseDSN determines the adapter + $databaseDSN = new DSN('mysql://' . $databaseDSN); + } + try { + $dsn = new DSN($project->getAttribute('database')); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $project->getAttribute('database')); + } + + $databaseHost = $databaseDSN->getHost(); + $pool = $pools->get($databaseHost); + + $adapter = new DatabasePool($pool); + $database = new Database($adapter, $cache); + $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setMetadata('host', \gethostname()) + ->setMetadata('project', $project->getId()) + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + // inside pools authorization needs to be set first + $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); + + // For separate pools (documentsdb/vectorsdb), check their own shared tables config. + // If not configured, use dedicated mode to avoid cross-engine tenant type mismatches. + if ($databaseHost !== $dsn->getHost()) { + $dbTypeSharedTables = match ($databaseType) { + DOCUMENTSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))), + VECTORSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))), + default => [], + }; + + if (\in_array($databaseHost, $dbTypeSharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($project->getSequence()) + ->setNamespace($databaseDSN->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + } elseif (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + $timeout = \intval($request->getHeader('x-appwrite-timeout')); + if (!empty($timeout) && Http::isDevelopment()) { + $database->setTimeout($timeout); + } + + // Register database event listeners for usage stats collection + $documentsMetric = METRIC_DOCUMENTS; + $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; + $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; + if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { + $documentsMetric = $databaseType . '.' . $documentsMetric; + $databaseIdDocumentsMetric = $databaseType . '.' . $databaseIdDocumentsMetric; + $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric; + } + $database + ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = 1; + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = -1; + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = $document->getAttribute('modified', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = -1 * $document->getAttribute('modified', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = $document->getAttribute('created', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }); + + return $database; + }; + + }, ['pools', 'cache', 'project', 'request', 'usage', 'authorization']); + + $container->set('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) { + return new TransactionState($dbForProject, $authorization, $getDatabasesDB); + }, ['dbForProject', 'authorization', 'getDatabasesDB']); + + $container->set('executionsRetentionCount', function (Document $project, array $plan) { + if ($project->getId() === 'console' || empty($plan)) { + return 0; + } + + return (int) ($plan['executionsRetentionCount'] ?? 100); + }, ['project', 'plan']); + + $container->set('deviceForFiles', function ($project, Telemetry $telemetry) { + return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + $container->set('deviceForSites', function ($project, Telemetry $telemetry) { + return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + $container->set('deviceForMigrations', function ($project, Telemetry $telemetry) { + return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + $container->set('deviceForFunctions', function ($project, Telemetry $telemetry) { + return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + $container->set('deviceForBuilds', function ($project, Telemetry $telemetry) { + return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + + $container->set('embeddingAgent', function ($register) { + $adapter = new Ollama(); + $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed')); + $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000')); + return new Agent($adapter); + }, ['register']); +}; diff --git a/app/init/worker/message.php b/app/init/worker/message.php new file mode 100644 index 0000000000..95477088ce --- /dev/null +++ b/app/init/worker/message.php @@ -0,0 +1,456 @@ +set('log', fn () => new Log(), []); + + $container->set('usage', fn () => new Context(), []); + + $container->set('authorization', function () { + $authorization = new Authorization(); + $authorization->disable(); + + return $authorization; + }, []); + + $container->set('dbForPlatform', function (Cache $cache, Group $pools, Authorization $authorization) { + $adapter = new DatabasePool($pools->get('console')); + $dbForPlatform = new Database($adapter, $cache); + + $dbForPlatform + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setNamespace('_console') + ->setDocumentType('users', User::class); + + return $dbForPlatform; + }, ['cache', 'pools', 'authorization']); + + $container->set('project', function ($message, Database $dbForPlatform) { + $payload = $message->getPayload() ?? []; + $project = new Document($payload['project'] ?? []); + + if ($project->isEmpty() || $project->getId() === 'console') { + return $project; + } + + return $dbForPlatform->getDocument('projects', $project->getId()); + }, ['message', 'dbForPlatform']); + + $container->set('dbForProject', function (Cache $cache, Group $pools, Document $project, Database $dbForPlatform, Authorization $authorization) { + if ($project->isEmpty() || $project->getId() === 'console') { + return $dbForPlatform; + } + + try { + $dsn = new DSN($project->getAttribute('database')); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $project->getAttribute('database')); + } + + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $database = new Database($adapter, $cache); + $database->setDocumentType('users', User::class); + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); + + return $database; + }, ['cache', 'pools', 'project', 'dbForPlatform', 'authorization']); + + $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) { + $databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools + + return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases): Database { + if ($project->isEmpty() || $project->getId() === 'console') { + return $dbForPlatform; + } + + try { + $dsn = new DSN($project->getAttribute('database')); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $project->getAttribute('database')); + } + + if (isset($databases[$dsn->getHost()])) { + $database = $databases[$dsn->getHost()]; + $database->setAuthorization($authorization); + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + + return $database; + } + + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $database = new Database($adapter, $cache); + + $databases[$dsn->getHost()] = $database; + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); + + return $database; + }; + }, ['pools', 'dbForPlatform', 'cache', 'authorization']); + + $container->set('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) { + return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database { + $projectDocument ??= $project; + $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', '')); + $databaseType = $database->getAttribute('type', ''); + + // Backwards-compatibility: older or seeded legacy databases may not have a DSN stored + // in the "database" attribute. In that case, fall back to the project's database DSN. + if ($databaseDSN === '') { + $databaseDSN = $projectDocument->getAttribute('database', ''); + } + + try { + $databaseDSN = new DSN($databaseDSN); + } catch (\InvalidArgumentException) { + $databaseDSN = new DSN('mysql://' . $databaseDSN); + } + + try { + $dsn = new DSN($projectDocument->getAttribute('database')); + } catch (\InvalidArgumentException) { + // Temporary fallback until all projects use shared tables + $dsn = new DSN('mysql://' . $projectDocument->getAttribute('database')); + } + + $pools = $register->get('pools'); + $databaseHost = $databaseDSN->getHost(); + $pool = $pools->get($databaseHost); + + $adapter = new DatabasePool($pool); + $database = new Database($adapter, $cache); + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization); + $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); + + $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); + + // For separate pools (documentsdb/vectorsdb), check their own shared tables config. + // If not configured, use dedicated mode to avoid cross-engine tenant type mismatches. + if ($databaseHost !== $dsn->getHost()) { + $dbTypeSharedTables = match ($databaseType) { + DOCUMENTSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))), + VECTORSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))), + default => [], + }; + + if (\in_array($databaseHost, $dbTypeSharedTables)) { + $database + ->setSharedTables(true) + ->setTenant($projectDocument->getSequence()) + ->setNamespace($databaseDSN->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $projectDocument->getSequence()); + } + } elseif (\in_array($dsn->getHost(), $sharedTables, true)) { + $database + ->setSharedTables(true) + ->setTenant($projectDocument->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $projectDocument->getSequence()); + } + + $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); + return $database; + }; + }, ['cache', 'register', 'project', 'authorization']); + + $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { + $database = null; + + return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) { + if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); + + return $database; + } + + $adapter = new DatabasePool($pools->get('logs')); + $database = new Database($adapter, $cache); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setSharedTables(true) + ->setNamespace('logsV1') + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER); + + if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { + $database->setTenant($project->getSequence()); + } + + return $database; + }; + }, ['pools', 'cache', 'authorization']); + + $container->set('abuseRetention', function () { + return \time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day + }, []); + + $container->set('auditRetention', function (Document $project) { + if ($project->getId() === 'console') { + return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months + } + + return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days + }, ['project']); + + $container->set('executionRetention', function () { + return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days + }, []); + + $container->set('queueForDatabase', function (Publisher $publisher) { + return new EventDatabase($publisher); + }, ['publisher']); + + $container->set('queueForMessaging', function (Publisher $publisher) { + return new Messaging($publisher); + }, ['publisher']); + + $container->set('queueForMails', function (Publisher $publisher) { + return new Mail($publisher); + }, ['publisher']); + + $container->set('queueForBuilds', function (Publisher $publisher) { + return new Build($publisher); + }, ['publisher']); + + $container->set('queueForScreenshots', function (Publisher $publisher) { + return new Screenshot($publisher); + }, ['publisher']); + + $container->set('queueForDeletes', function (Publisher $publisher) { + return new Delete($publisher); + }, ['publisher']); + + $container->set('queueForEvents', function (Publisher $publisher) { + 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']); + + $container->set('queueForFunctions', function (Publisher $publisher) { + return new Func($publisher); + }, ['publisher']); + + $container->set('queueForRealtime', function () { + return new Realtime(); + }, []); + + $container->set('queueForCertificates', function (Publisher $publisher) { + return new Certificate($publisher); + }, ['publisher']); + + $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']); + + $container->set('deviceForMigrations', function (Document $project, Telemetry $telemetry) { + return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + + $container->set('deviceForFunctions', function (Document $project, Telemetry $telemetry) { + return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + + $container->set('deviceForFiles', function (Document $project, Telemetry $telemetry) { + return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + + $container->set('deviceForBuilds', function (Document $project, Telemetry $telemetry) { + return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + + $container->set('deviceForCache', function (Document $project, Telemetry $telemetry) { + return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId())); + }, ['project', 'telemetry']); + + $container->set('logError', function (Registry $register, Document $project) { + return function (Throwable $error, string $namespace, string $action, ?array $extras = null) use ($register, $project) { + $logger = $register->get('logger'); + + if ($logger) { + $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); + + $log = new Log(); + $log->setNamespace($namespace); + $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname())); + $log->setVersion($version); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($error->getMessage()); + + $log->addTag('code', $error->getCode()); + $log->addTag('verboseType', \get_class($error)); + $log->addTag('projectId', $project->getId() ?? ''); + + $log->addExtra('file', $error->getFile()); + $log->addExtra('line', $error->getLine()); + $log->addExtra('trace', $error->getTraceAsString()); + + if ($error->getPrevious() !== null) { + if ($error->getPrevious()->getMessage() != $error->getMessage()) { + $log->addExtra('previousMessage', $error->getPrevious()->getMessage()); + } + $log->addExtra('previousFile', $error->getPrevious()->getFile()); + $log->addExtra('previousLine', $error->getPrevious()->getLine()); + } + + foreach (($extras ?? []) as $key => $value) { + $log->addExtra($key, $value); + } + + $log->setAction($action); + + $isProduction = System::getEnv('_APP_ENV', 'development') === 'production'; + $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); + + try { + $responseCode = $logger->addLog($log); + Console::info('Error log pushed with status code: ' . $responseCode); + } catch (Throwable $th) { + Console::error('Error pushing log: ' . $th->getMessage()); + } + } + + Console::warning("Failed: {$error->getMessage()}"); + Console::warning($error->getTraceAsString()); + + if ($error->getPrevious() !== null) { + if ($error->getPrevious()->getMessage() != $error->getMessage()) { + Console::warning("Previous Failed: {$error->getPrevious()->getMessage()}"); + } + Console::warning("Previous File: {$error->getPrevious()->getFile()} Line: {$error->getPrevious()->getLine()}"); + } + }; + }, ['register', 'project']); + + $container->set('getAudit', function (Database $dbForPlatform, callable $getProjectDB) { + return function (Document $project) use ($dbForPlatform, $getProjectDB) { + if ($project->isEmpty() || $project->getId() === 'console') { + $adapter = new AdapterDatabase($dbForPlatform); + + return new UtopiaAudit($adapter); + } + + $dbForProject = $getProjectDB($project); + $adapter = new AdapterDatabase($dbForProject); + + return new UtopiaAudit($adapter); + }; + }, ['dbForPlatform', 'getProjectDB']); + + $container->set('executionsRetentionCount', function (Document $project, array $plan) { + if ($project->getId() === 'console' || empty($plan)) { + return 0; + } + + return (int) ($plan['executionsRetentionCount'] ?? 100); + }, ['project', 'plan']); +}; diff --git a/app/realtime.php b/app/realtime.php index 67cfb19e2a..97ea7e32d5 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -33,7 +33,9 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; +use Utopia\DI\Container; use Utopia\DSN\DSN; +use Utopia\Http\Adapter\FPM\Server as HttpServer; use Utopia\Http\Http; use Utopia\Logger\Log; use Utopia\Pools\Group; @@ -48,6 +50,8 @@ use Utopia\WebSocket\Server; */ require_once __DIR__ . '/init.php'; +$registerRequestResources ??= require __DIR__ . '/init/resources/request.php'; + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); // Log uncaught exceptions in one line instead of relying on Swoole's full backtrace dump @@ -240,6 +244,11 @@ if (!function_exists('triggerStats')) { } } +global $container; +$container->set('pools', function ($register) { + return $register->get('pools'); +}, ['register']); + $realtime = getRealtime(); /** @@ -614,16 +623,22 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); -$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) { - $app = new Http('UTC'); +$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerRequestResources) { + global $container; $request = new Request($request); $response = new Response(new SwooleResponse()); Console::info("Connection open (user: {$connection})"); - Http::setResource('pools', fn () => $register->get('pools')); - Http::setResource('request', fn () => $request); - Http::setResource('response', fn () => $response); + $connectionContainer = new Container($container); + + $adapter = new HttpServer($connectionContainer); + $app = new Http($adapter, 'UTC'); + $connectionContainer->set('utopia', fn () => $app); + $connectionContainer->set('request', fn () => $request); + $connectionContainer->set('response', fn () => $response); + + $registerRequestResources($connectionContainer); $project = null; $logUser = null; diff --git a/app/worker.php b/app/worker.php index d573a60231..e55abb587c 100644 --- a/app/worker.php +++ b/app/worker.php @@ -1,564 +1,69 @@ $register); +global $container; +$container->set('pools', function ($register) { + return $register->get('pools'); +}, ['register']); -Server::setResource('authorization', function () { +$container->set('authorization', function () { $authorization = new Authorization(); $authorization->disable(); return $authorization; }, []); -Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, Authorization $authorization) { - $pools = $register->get('pools'); - $adapter = new DatabasePool($pools->get('console')); - $dbForPlatform = new Database($adapter, $cache); +$container->set('project', fn () => new Document([]), []); - $dbForPlatform - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization) - ->setNamespace('_console') - ->setDocumentType('users', User::class); +$container->set('log', fn () => new Log(), []); - return $dbForPlatform; -}, ['cache', 'register', 'authorization']); - -Server::setResource('project', function (Message $message, Database $dbForPlatform) { - $payload = $message->getPayload() ?? []; - $project = new Document($payload['project'] ?? []); - - if ($project->getId() === 'console') { - return $project; - } - - return $dbForPlatform->getDocument('projects', $project->getId()); -}, ['message', 'dbForPlatform']); - -Server::setResource('dbForProject', function (Cache $cache, Registry $register, Message $message, Document $project, Database $dbForPlatform, Authorization $authorization) { - if ($project->isEmpty() || $project->getId() === 'console') { - return $dbForPlatform; - } - - $pools = $register->get('pools'); - - try { - $dsn = new DSN($project->getAttribute('database')); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $project->getAttribute('database')); - } - - $adapter = new DatabasePool($pools->get($dsn->getHost())); - $database = new Database($adapter, $cache); - $database->setDocumentType('users', User::class); - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn->getHost(), $sharedTables)) { - $database - ->setSharedTables(true) - ->setTenant($project->getSequence()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $database - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - - $database - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization) - ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); - - return $database; -}, ['cache', 'register', 'message', 'project', 'dbForPlatform', 'authorization']); - -Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) { - $databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools - - return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases): Database { - if ($project->isEmpty() || $project->getId() === 'console') { - return $dbForPlatform; - } - - try { - $dsn = new DSN($project->getAttribute('database')); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $project->getAttribute('database')); - } - - if (isset($databases[$dsn->getHost()])) { - $database = $databases[$dsn->getHost()]; - $database->setAuthorization($authorization); - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn->getHost(), $sharedTables)) { - $database - ->setSharedTables(true) - ->setTenant($project->getSequence()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $database - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - - return $database; - } - - $adapter = new DatabasePool($pools->get($dsn->getHost())); - $database = new Database($adapter, $cache); - - $databases[$dsn->getHost()] = $database; - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn->getHost(), $sharedTables)) { - $database - ->setSharedTables(true) - ->setTenant($project->getSequence()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $database - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - - $database - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization) - ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); - - return $database; - }; -}, ['pools', 'dbForPlatform', 'cache', 'authorization']); - -Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { - $database = null; - - return function (?Document $project = null) use ($pools, $cache, $database, $authorization) { - if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant($project->getSequence()); - return $database; - } - - $adapter = new DatabasePool($pools->get('logs')); - $database = new Database($adapter, $cache); - - $database - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization) - ->setSharedTables(true) - ->setNamespace('logsV1') - ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER) - ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER); - - if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant($project->getSequence()); - } - - return $database; - }; -}, ['pools', 'cache', 'authorization']); - -Server::setResource('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) { - return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database { - $projectDocument ??= $project; - $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', '')); - $databaseType = $database->getAttribute('type', ''); - - // Backwards‑compatibility: older or seeded legacy databases may not have a DSN stored - // in the "database" attribute. In that case, fall back to the project's database DSN. - if ($databaseDSN === '') { - $databaseDSN = $projectDocument->getAttribute('database', ''); - } - - try { - $databaseDSN = new DSN($databaseDSN); - } catch (\InvalidArgumentException) { - $databaseDSN = new DSN('mysql://'.$databaseDSN); - } - - try { - $dsn = new DSN($projectDocument->getAttribute('database')); - } catch (\InvalidArgumentException) { - // Temporary fallback until all projects use shared tables - $dsn = new DSN('mysql://' . $projectDocument->getAttribute('database')); - } - - $pools = $register->get('pools'); - $pool = $pools->get($databaseDSN->getHost()); - - $adapter = new DatabasePool($pool); - $database = new Database($adapter, $cache); - $database - ->setDatabase(APP_DATABASE) - ->setAuthorization($authorization); - $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn->getHost(), $sharedTables, true)) { - $database - ->setSharedTables(true) - ->setTenant((int) $projectDocument->getSequence()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $database - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $projectDocument->getSequence()); - } - - $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); - return $database; - }; -}, ['cache', 'register', 'project', 'authorization']); - -Server::setResource('abuseRetention', function () { - return time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day -}); - -Server::setResource('auditRetention', function (Document $project) { - if ($project->getId() === 'console') { - return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months - } - - return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days -}, ['project']); - -Server::setResource('executionRetention', function () { - return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days -}); - -Server::setResource('cache', function (Registry $register) { - $pools = $register->get('pools'); - $list = Config::getParam('pools-cache', []); - $adapters = []; - - foreach ($list as $value) { - $adapters[] = new CachePool($pools->get($value)); - } - - return new Cache(new Sharding($adapters)); -}, ['register']); - -Server::setResource('redis', function () { - $host = System::getEnv('_APP_REDIS_HOST', 'localhost'); - $port = System::getEnv('_APP_REDIS_PORT', 6379); - $pass = System::getEnv('_APP_REDIS_PASS', ''); - - $redis = new \Redis(); - @$redis->pconnect($host, (int) $port); - if ($pass) { - $redis->auth($pass); - } - $redis->setOption(\Redis::OPT_READ_TIMEOUT, -1); - - return $redis; -}); - -Server::setResource('timelimit', function (\Redis $redis) { - return function (string $key, int $limit, int $time) use ($redis) { - return new TimeLimitRedis($key, $limit, $time, $redis); - }; -}, ['redis']); - -Server::setResource('log', fn () => new Log()); - -Server::setResource('publisher', function (Group $pools) { - return new BrokerPool(publisher: $pools->get('publisher')); -}, ['pools']); - -Server::setResource('publisherDatabases', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); - -Server::setResource('publisherFunctions', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); - -Server::setResource('publisherMigrations', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); - -Server::setResource('publisherMessaging', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); - -Server::setResource('consumer', function (Group $pools) { +$container->set('consumer', function (Group $pools) { return new BrokerPool(consumer: $pools->get('consumer')); }, ['pools']); -Server::setResource('consumerDatabases', function (BrokerPool $consumer) { +$container->set('consumerDatabases', function (BrokerPool $consumer) { return $consumer; }, ['consumer']); -Server::setResource('consumerMigrations', function (BrokerPool $consumer) { +$container->set('consumerMigrations', function (BrokerPool $consumer) { return $consumer; }, ['consumer']); -Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) { +$container->set('consumerStatsUsage', function (BrokerPool $consumer) { return $consumer; }, ['consumer']); -Server::setResource('usage', function () { - return new Context(); -}, []); -Server::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( - $publisher, - new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) -), ['publisher']); - -Server::setResource('queueForDatabase', function (Publisher $publisher) { - return new EventDatabase($publisher); -}, ['publisher']); - -Server::setResource('queueForMessaging', function (Publisher $publisher) { - return new Messaging($publisher); -}, ['publisher']); - -Server::setResource('queueForMails', function (Publisher $publisher) { - return new Mail($publisher); -}, ['publisher']); - -Server::setResource('queueForBuilds', function (Publisher $publisher) { - return new Build($publisher); -}, ['publisher']); - -Server::setResource('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); -}, ['publisher']); - -Server::setResource('queueForDeletes', function (Publisher $publisher) { - return new Delete($publisher); -}, ['publisher']); - -Server::setResource('queueForEvents', function (Publisher $publisher) { - return new Event($publisher); -}, ['publisher']); - -Server::setResource('queueForAudits', function (Publisher $publisher) { - return new Audit($publisher); -}, ['publisher']); - -Server::setResource('queueForWebhooks', function (Publisher $publisher) { - return new Webhook($publisher); -}, ['publisher']); - -Server::setResource('queueForFunctions', function (Publisher $publisher) { - return new Func($publisher); -}, ['publisher']); - -Server::setResource('queueForRealtime', function () { - return new Realtime(); -}, []); - -Server::setResource('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); -}, ['publisher']); - -Server::setResource('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); -}, ['publisher']); - -Server::setResource('logger', function (Registry $register) { - return $register->get('logger'); -}, ['register']); - -Server::setResource('pools', function (Registry $register) { - return $register->get('pools'); -}, ['register']); - -Server::setResource('telemetry', fn () => new NoTelemetry()); - -Server::setResource('deviceForSites', function (Document $project, Telemetry $telemetry) { - return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); -}, ['project', 'telemetry']); - -Server::setResource('deviceForMigrations', function (Document $project, Telemetry $telemetry) { - return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); - -Server::setResource('deviceForFunctions', function (Document $project, Telemetry $telemetry) { - return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); - -Server::setResource('deviceForFiles', function (Document $project, Telemetry $telemetry) { - return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); - -Server::setResource('deviceForBuilds', function (Document $project, Telemetry $telemetry) { - return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())); -}, ['project', 'telemetry']); - -Server::setResource('deviceForCache', function (Document $project, Telemetry $telemetry) { - return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId())); -}, ['project', 'telemetry']); - -Server::setResource( - 'isResourceBlocked', - fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false -); - -Server::setResource('plan', function (array $plan = []) { - return []; -}); - -Server::setResource('certificates', function () { +$container->set('certificates', function () { $email = System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')); if (empty($email)) { throw new Exception('You must set a valid security email address (_APP_EMAIL_CERTIFICATES) to issue a LetsEncrypt SSL certificate.'); } return new LetsEncrypt($email); -}); +}, []); -Server::setResource('logError', function (Registry $register, Document $project) { - return function (Throwable $error, string $namespace, string $action, ?array $extras = null) use ($register, $project) { - $logger = $register->get('logger'); - - if ($logger) { - $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); - - $log = new Log(); - $log->setNamespace($namespace); - $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname())); - $log->setVersion($version); - $log->setType(Log::TYPE_ERROR); - $log->setMessage($error->getMessage()); - - $log->addTag('code', $error->getCode()); - $log->addTag('verboseType', get_class($error)); - $log->addTag('projectId', $project->getId() ?? ''); - - $log->addExtra('file', $error->getFile()); - $log->addExtra('line', $error->getLine()); - $log->addExtra('trace', $error->getTraceAsString()); - - if ($error->getPrevious() !== null) { - if ($error->getPrevious()->getMessage() != $error->getMessage()) { - $log->addExtra('previousMessage', $error->getPrevious()->getMessage()); - } - $log->addExtra('previousFile', $error->getPrevious()->getFile()); - $log->addExtra('previousLine', $error->getPrevious()->getLine()); - } - - foreach (($extras ?? []) as $key => $value) { - $log->addExtra($key, $value); - } - - $log->setAction($action); - - $isProduction = System::getEnv('_APP_ENV', 'development') === 'production'; - $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); - - try { - $responseCode = $logger->addLog($log); - Console::info('Error log pushed with status code: ' . $responseCode); - } catch (Throwable $th) { - Console::error('Error pushing log: ' . $th->getMessage()); - } - } - - Console::warning("Failed: {$error->getMessage()}"); - Console::warning($error->getTraceAsString()); - - if ($error->getPrevious() !== null) { - if ($error->getPrevious()->getMessage() != $error->getMessage()) { - Console::warning("Previous Failed: {$error->getPrevious()->getMessage()}"); - } - Console::warning("Previous File: {$error->getPrevious()->getFile()} Line: {$error->getPrevious()->getLine()}"); - } - }; -}, ['register', 'project']); - -Server::setResource('executor', fn () => new Executor()); - -Server::setResource('getAudit', function (Database $dbForPlatform, callable $getProjectDB) { - return function (Document $project) use ($dbForPlatform, $getProjectDB) { - if ($project->isEmpty() || $project->getId() === 'console') { - $adapter = new AdapterDatabase($dbForPlatform); - - return new UtopiaAudit($adapter); - } - - $dbForProject = $getProjectDB($project); - $adapter = new AdapterDatabase($dbForProject); - - return new UtopiaAudit($adapter); - }; -}, ['dbForPlatform', 'getProjectDB']); - -Server::setResource('executionsRetentionCount', function (Document $project, array $plan) { - if ($project->getId() === 'console' || empty($plan)) { - return 0; - } - - return (int) ($plan['executionsRetentionCount'] ?? 100); -}, ['project', 'plan']); - -$pools = $register->get('pools'); $platform = new Appwrite(); -$args = $platform->getEnv('argv'); +$args = $_SERVER['argv'] ?? []; if (! isset($args[1])) { Console::error('Missing worker name'); @@ -574,38 +79,45 @@ if (\str_starts_with($workerName, 'databases')) { $queueName = System::getEnv('_APP_QUEUE_NAME', 'v1-' . strtolower($workerName)); } +/** @var \Utopia\Pools\Group $pools */ +$pools = $container->get('pools'); + +$adapter = new Swoole( + $pools->get('consumer')->pop()->getResource(), + System::getEnv('_APP_WORKERS_NUM', 1), + $queueName +); + +$worker = new Server($adapter, $container); + try { - /** - * Any worker can be configured with the following env vars: - * - _APP_WORKERS_NUM The total number of worker processes - * - _APP_WORKER_PER_CORE The number of worker processes per core (ignored if _APP_WORKERS_NUM is set) - * - _APP_QUEUE_NAME The name of the queue to read for database events - */ + $worker->init()->action(function () use ($worker, $registerWorkerMessageResources) { + $registerWorkerMessageResources($worker->getContainer()); + }); + + $container->set('bus', function ($register) use ($worker) { + return $register->get('bus')->setResolver( + fn (string $name) => $worker->getContainer()->get($name) + ); + }, ['register']); + + $platform->setWorker($worker); $platform->init(Service::TYPE_WORKER, [ - 'workersNum' => System::getEnv('_APP_WORKERS_NUM', 1), - 'connection' => $pools->get('consumer')->pop()->getResource(), - 'workerName' => strtolower($workerName) ?? null, - 'queueName' => $queueName, + 'workerName' => strtolower($workerName), ]); } catch (\Throwable $e) { Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine()); + Console::exit(1); } -$worker = $platform->getWorker(); - -Server::setResource('bus', function ($register) use ($worker) { - return $register->get('bus')->setResolver(fn (string $name) => $worker->getResource($name)); -}, ['register']); - $worker ->error() ->inject('error') ->inject('logger') ->inject('log') - ->inject('pools') ->inject('project') ->inject('authorization') - ->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($queueName) { + ->action(function (Throwable $error, ?Logger $logger, Log $log, Document $project, Authorization $authorization) use ($queueName) { $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); if ($logger) { diff --git a/composer.json b/composer.json index 0f4dcfb8db..d3474361e2 100644 --- a/composer.json +++ b/composer.json @@ -52,34 +52,34 @@ "appwrite/php-runtimes": "0.19.*", "appwrite/php-clamav": "2.0.*", "utopia-php/abuse": "1.2.*", + "utopia-php/agents": "1.2.*", "utopia-php/analytics": "0.15.*", "utopia-php/audit": "2.2.*", "utopia-php/auth": "0.5.*", "utopia-php/cache": "1.0.*", - "utopia-php/cli": "0.22.*", + "utopia-php/cli": "0.23.*", "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", "utopia-php/database": "5.*", - "utopia-php/agents": "1.*", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", "utopia-php/dns": "1.6.*", "utopia-php/dsn": "0.2.1", - "utopia-php/framework": "0.33.*", + "utopia-php/framework": "0.34.*", "utopia-php/fetch": "0.5.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.9.*", - "utopia-php/platform": "0.7.*", + "utopia-php/platform": "0.12.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", "utopia-php/preloader": "0.2.*", - "utopia-php/queue": "0.15.*", - "utopia-php/servers": "0.2.5", + "utopia-php/queue": "0.17.*", + "utopia-php/servers": "0.3.*", "utopia-php/registry": "0.5.*", "utopia-php/storage": "1.0.*", "utopia-php/system": "0.10.*", @@ -94,8 +94,7 @@ "spomky-labs/otphp": "11.*", "webonyx/graphql-php": "14.11.*", "league/csv": "9.14.*", - "enshrined/svg-sanitize": "0.22.*", - "utopia-php/di": "0.1.0" + "enshrined/svg-sanitize": "0.22.*" }, "require-dev": { "ext-fileinfo": "*", diff --git a/composer.lock b/composer.lock index 420dddc9a5..813dfe3c1d 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": "4fe91e67f343fbe6deac1fdc7eda949f", + "content-hash": "e9c38bbebc60849e70e3640aaa4422cd", "packages": [ { "name": "adhocore/jwt", @@ -3403,16 +3403,16 @@ }, { "name": "utopia-php/agents", - "version": "1.3.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/utopia-php/agents.git", - "reference": "06064fd9fb19b77ae45a12ec7bcbc17670912c30" + "reference": "052227953678a30ecc4b5467401fcb0b2386471e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/agents/zipball/06064fd9fb19b77ae45a12ec7bcbc17670912c30", - "reference": "06064fd9fb19b77ae45a12ec7bcbc17670912c30", + "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e", + "reference": "052227953678a30ecc4b5467401fcb0b2386471e", "shasum": "" }, "require": { @@ -3450,9 +3450,9 @@ ], "support": { "issues": "https://github.com/utopia-php/agents/issues", - "source": "https://github.com/utopia-php/agents/tree/1.3.0" + "source": "https://github.com/utopia-php/agents/tree/1.2.1" }, - "time": "2026-03-26T03:51:11+00:00" + "time": "2026-02-24T06:03:55+00:00" }, { "name": "utopia-php/analytics", @@ -3658,21 +3658,21 @@ }, { "name": "utopia-php/cli", - "version": "0.22.0", + "version": "0.23.1", "source": { "type": "git", "url": "https://github.com/utopia-php/cli.git", - "reference": "a7ac387ee626fd27075a87e836fb72c5be38add4" + "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cli/zipball/a7ac387ee626fd27075a87e836fb72c5be38add4", - "reference": "a7ac387ee626fd27075a87e836fb72c5be38add4", + "url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621", + "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621", "shasum": "" }, "require": { "php": ">=7.4", - "utopia-php/servers": "0.2.*" + "utopia-php/servers": "0.3.*" }, "require-dev": { "laravel/pint": "1.2.*", @@ -3703,9 +3703,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cli/issues", - "source": "https://github.com/utopia-php/cli/tree/0.22.0" + "source": "https://github.com/utopia-php/cli/tree/0.23.1" }, - "time": "2025-10-21T10:42:45+00:00" + "time": "2026-04-05T15:27:35+00:00" }, { "name": "utopia-php/compression", @@ -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", @@ -3954,25 +3954,26 @@ }, { "name": "utopia-php/di", - "version": "0.1.0", + "version": "0.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/di.git", - "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31" + "reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31", - "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "url": "https://api.github.com/repos/utopia-php/di/zipball/07025d721ed5d9be27932e8e640acf1467fc4b9d", + "reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "psr/container": "^2.0" }, "require-dev": { - "laravel/pint": "^1.2", + "laravel/pint": "^1.27", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.5.25", "swoole/ide-helper": "4.8.3" }, @@ -3989,16 +3990,18 @@ ], "description": "A simple and lite library for managing dependency injections", "keywords": [ - "framework", - "http", + "PSR-11", + "container", + "dependency-injection", + "di", "php", - "upf" + "utopia" ], "support": { "issues": "https://github.com/utopia-php/di/issues", - "source": "https://github.com/utopia-php/di/tree/0.1.0" + "source": "https://github.com/utopia-php/di/tree/0.3.2" }, - "time": "2024-08-08T14:35:19+00:00" + "time": "2026-03-21T07:42:10+00:00" }, { "name": "utopia-php/dns", @@ -4268,30 +4271,34 @@ }, { "name": "utopia-php/framework", - "version": "0.33.41", + "version": "0.34.18", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06" + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/0f3bf2377c867e547c929c3733b8224afee6ef06", - "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06", + "url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", "shasum": "" }, "require": { - "php": ">=8.3", + "ext-swoole": "*", + "php": ">=8.2", "utopia-php/compression": "0.1.*", + "utopia-php/di": "0.3.*", + "utopia-php/servers": "0.3.*", "utopia-php/telemetry": "0.2.*", "utopia-php/validators": "0.2.*" }, "require-dev": { + "doctrine/instantiator": "^1.5", "laravel/pint": "1.*", - "phpbench/phpbench": "1.*", + "phpbench/phpbench": "^1.2", "phpstan/phpstan": "1.*", - "phpunit/phpunit": "9.*", - "swoole/ide-helper": "^6.0" + "phpunit/phpunit": "^9.5.25", + "swoole/ide-helper": "4.8.3" }, "type": "library", "autoload": { @@ -4303,17 +4310,72 @@ "license": [ "MIT" ], - "description": "A simple, light and advanced PHP framework", + "description": "A simple, light and advanced PHP HTTP framework", "keywords": [ "framework", + "http", "php", "upf" ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.41" + "source": "https://github.com/utopia-php/http/tree/0.34.18" }, - "time": "2026-02-24T12:01:28+00:00" + "time": "2026-04-07T08:06:39+00:00" + }, + { + "name": "utopia-php/http", + "version": "0.34.16", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/http.git", + "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/http/zipball/2b4021ba3f9d476264ce9fd6703d6c79de9add7f", + "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f", + "shasum": "" + }, + "require": { + "ext-swoole": "*", + "php": ">=8.2", + "utopia-php/compression": "0.1.*", + "utopia-php/di": "0.3.*", + "utopia-php/servers": "0.3.*", + "utopia-php/telemetry": "0.2.*", + "utopia-php/validators": "0.2.*" + }, + "require-dev": { + "doctrine/instantiator": "^1.5", + "laravel/pint": "1.*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "^9.5.25", + "swoole/ide-helper": "4.8.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple, light and advanced PHP HTTP framework", + "keywords": [ + "framework", + "http", + "php", + "upf" + ], + "support": { + "issues": "https://github.com/utopia-php/http/issues", + "source": "https://github.com/utopia-php/http/tree/0.34.16" + }, + "time": "2026-03-20T10:39:07+00:00" }, { "name": "utopia-php/image", @@ -4634,30 +4696,30 @@ }, { "name": "utopia-php/platform", - "version": "0.7.16", + "version": "0.12.0", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "34e67e4b80b5741c380071fe765fbc12a132de4f" + "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/34e67e4b80b5741c380071fe765fbc12a132de4f", - "reference": "34e67e4b80b5741c380071fe765fbc12a132de4f", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/068ee46228f0c3972e6b569f2c86b6c80fe583d8", + "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8", "shasum": "" }, "require": { "ext-json": "*", "ext-redis": "*", - "php": ">=8.0", - "utopia-php/cli": "0.22.*", - "utopia-php/framework": "0.33.*", - "utopia-php/queue": "0.15.*" + "php": ">=8.1", + "utopia-php/cli": "0.23.*", + "utopia-php/http": "0.34.*", + "utopia-php/queue": "0.17.*", + "utopia-php/servers": "0.3.*" }, "require-dev": { - "laravel/pint": "1.*", - "phpstan/phpstan": "2.*", - "phpunit/phpunit": "9.*" + "laravel/pint": "1.2.*", + "phpunit/phpunit": "^9.3" }, "type": "library", "autoload": { @@ -4679,9 +4741,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.7.16" + "source": "https://github.com/utopia-php/platform/tree/0.12.0" }, - "time": "2026-02-11T06:36:48+00:00" + "time": "2026-03-31T14:44:23+00:00" }, { "name": "utopia-php/pools", @@ -4791,32 +4853,33 @@ }, { "name": "utopia-php/queue", - "version": "0.15.6", + "version": "0.17.0", "source": { "type": "git", "url": "https://github.com/utopia-php/queue.git", - "reference": "08e361d69610f371382b344c369eef355ca414b4" + "reference": "0fbc7d7312f5cf76ec112513fb93317000901f5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/queue/zipball/08e361d69610f371382b344c369eef355ca414b4", - "reference": "08e361d69610f371382b344c369eef355ca414b4", + "url": "https://api.github.com/repos/utopia-php/queue/zipball/0fbc7d7312f5cf76ec112513fb93317000901f5f", + "reference": "0fbc7d7312f5cf76ec112513fb93317000901f5f", "shasum": "" }, "require": { "php": ">=8.3", "php-amqplib/php-amqplib": "^3.7", + "utopia-php/di": "0.3.*", "utopia-php/fetch": "0.5.*", "utopia-php/pools": "1.*", - "utopia-php/servers": "0.2.*", + "utopia-php/servers": "0.3.*", "utopia-php/telemetry": "0.2.*", "utopia-php/validators": "0.2.*" }, "require-dev": { "ext-redis": "*", - "laravel/pint": "^0.2.3", + "laravel/pint": "^1.0", "phpstan/phpstan": "^1.8", - "phpunit/phpunit": "^9.5.5", + "phpunit/phpunit": "^11.0", "swoole/ide-helper": "4.8.8", "workerman/workerman": "^4.0" }, @@ -4851,9 +4914,9 @@ ], "support": { "issues": "https://github.com/utopia-php/queue/issues", - "source": "https://github.com/utopia-php/queue/tree/0.15.6" + "source": "https://github.com/utopia-php/queue/tree/0.17.0" }, - "time": "2026-02-23T13:03:51+00:00" + "time": "2026-03-23T16:21:31+00:00" }, { "name": "utopia-php/registry", @@ -4909,21 +4972,21 @@ }, { "name": "utopia-php/servers", - "version": "0.2.5", + "version": "0.3.0", "source": { "type": "git", "url": "https://github.com/utopia-php/servers.git", - "reference": "4770e879a90685af4ba14e7e5d95d0a17c7fdf03" + "reference": "235be31200df9437fc96a1c270ffef4c64fafe52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/servers/zipball/4770e879a90685af4ba14e7e5d95d0a17c7fdf03", - "reference": "4770e879a90685af4ba14e7e5d95d0a17c7fdf03", + "url": "https://api.github.com/repos/utopia-php/servers/zipball/235be31200df9437fc96a1c270ffef4c64fafe52", + "reference": "235be31200df9437fc96a1c270ffef4c64fafe52", "shasum": "" }, "require": { - "php": ">=8.0", - "utopia-php/di": "0.1.*", + "php": ">=8.2", + "utopia-php/di": "0.3.*", "utopia-php/validators": "0.*" }, "require-dev": { @@ -4957,9 +5020,9 @@ ], "support": { "issues": "https://github.com/utopia-php/servers/issues", - "source": "https://github.com/utopia-php/servers/tree/0.2.5" + "source": "https://github.com/utopia-php/servers/tree/0.3.0" }, - "time": "2026-02-10T04:21:53+00:00" + "time": "2026-03-13T11:31:42+00:00" }, { "name": "utopia-php/span", diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index c015d2fdcb..58a21b5517 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -333,6 +333,8 @@ class Exception extends \Exception /** Platform */ public const string PLATFORM_NOT_FOUND = 'platform_not_found'; + public const string PLATFORM_METHOD_UNSUPPORTED = 'platform_method_unsupported'; + public const string PLATFORM_ALREADY_EXISTS = 'platform_already_exists'; /** GraphqQL */ public const string GRAPHQL_NO_QUERY = 'graphql_no_query'; diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index e422bcbf96..689724d9f1 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -26,9 +26,9 @@ class Resolvers ): callable { return static fn ($type, $args, $context, $info) => new Swoole( function (callable $resolve, callable $reject) use ($utopia, $route, $args) { - $utopia = $utopia->getResource('utopia:graphql', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $path = $route->getPath(); foreach ($args as $key => $value) { @@ -93,9 +93,9 @@ class Resolvers ): callable { 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', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('GET'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -124,9 +124,9 @@ class Resolvers ): callable { 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', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('GET'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -160,9 +160,9 @@ class Resolvers ): callable { 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', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('POST'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -192,9 +192,9 @@ class Resolvers ): callable { 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', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('PATCH'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -222,9 +222,9 @@ class Resolvers ): callable { 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', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); $request->setMethod('DELETE'); $request->setURI($url($databaseId, $collectionId, $args)); @@ -266,7 +266,7 @@ class Resolvers try { $route = $utopia->match($request, fresh: true); - $utopia->execute($route, $request, $response); + $utopia->execute($route, $request); } catch (\Throwable $e) { if ($beforeReject) { $e = $beforeReject($e); diff --git a/src/Appwrite/GraphQL/Schema.php b/src/Appwrite/GraphQL/Schema.php index aa68fd28a1..4ff96fb635 100644 --- a/src/Appwrite/GraphQL/Schema.php +++ b/src/Appwrite/GraphQL/Schema.php @@ -32,10 +32,6 @@ class Schema array $urls, array $params, ): GQLSchema { - Http::setResource('utopia:graphql', static function () use ($utopia) { - return $utopia; - }); - if (!empty(self::$schema)) { return self::$schema; } diff --git a/src/Appwrite/Network/Platform.php b/src/Appwrite/Network/Platform.php index 1cf5de91d1..9e3a565f18 100644 --- a/src/Appwrite/Network/Platform.php +++ b/src/Appwrite/Network/Platform.php @@ -6,20 +6,10 @@ class Platform { public const TYPE_UNKNOWN = 'unknown'; public const TYPE_WEB = 'web'; - public const TYPE_FLUTTER_IOS = 'flutter-ios'; - public const TYPE_FLUTTER_ANDROID = 'flutter-android'; - public const TYPE_FLUTTER_MACOS = 'flutter-macos'; - public const TYPE_FLUTTER_WINDOWS = 'flutter-windows'; - public const TYPE_FLUTTER_LINUX = 'flutter-linux'; - public const TYPE_FLUTTER_WEB = 'flutter-web'; - public const TYPE_APPLE_IOS = 'apple-ios'; - public const TYPE_APPLE_MACOS = 'apple-macos'; - public const TYPE_APPLE_WATCHOS = 'apple-watchos'; - public const TYPE_APPLE_TVOS = 'apple-tvos'; + public const TYPE_APPLE = 'apple'; public const TYPE_ANDROID = 'android'; - public const TYPE_UNITY = 'unity'; - public const TYPE_REACT_NATIVE_IOS = 'react-native-ios'; - public const TYPE_REACT_NATIVE_ANDROID = 'react-native-android'; + public const TYPE_WINDOWS = 'windows'; + public const TYPE_LINUX = 'linux'; public const TYPE_SCHEME = 'scheme'; public const SCHEME_HTTP = 'http'; @@ -78,24 +68,14 @@ class Platform switch ($type) { case self::TYPE_WEB: - case self::TYPE_FLUTTER_WEB: if (!empty($hostname)) { $hostnames[] = $hostname; } break; - case self::TYPE_FLUTTER_IOS: - case self::TYPE_FLUTTER_ANDROID: - case self::TYPE_FLUTTER_MACOS: - case self::TYPE_FLUTTER_WINDOWS: - case self::TYPE_FLUTTER_LINUX: case self::TYPE_ANDROID: - case self::TYPE_APPLE_IOS: - case self::TYPE_APPLE_MACOS: - case self::TYPE_APPLE_WATCHOS: - case self::TYPE_APPLE_TVOS: - case self::TYPE_REACT_NATIVE_IOS: - case self::TYPE_REACT_NATIVE_ANDROID: - case self::TYPE_UNITY: + case self::TYPE_WINDOWS: + case self::TYPE_LINUX: + case self::TYPE_APPLE: if (!empty($key)) { $hostnames[] = $key; } @@ -121,37 +101,24 @@ class Platform } break; case self::TYPE_WEB: - case self::TYPE_FLUTTER_WEB: $schemes[] = self::SCHEME_HTTP; $schemes[] = self::SCHEME_HTTPS; break; - case self::TYPE_FLUTTER_IOS: - case self::TYPE_APPLE_IOS: - case self::TYPE_REACT_NATIVE_IOS: - $schemes[] = self::SCHEME_IOS; - break; - case self::TYPE_FLUTTER_ANDROID: case self::TYPE_ANDROID: - case self::TYPE_REACT_NATIVE_ANDROID: $schemes[] = self::SCHEME_ANDROID; break; - case self::TYPE_FLUTTER_MACOS: - case self::TYPE_APPLE_MACOS: + case self::TYPE_APPLE: + $schemes[] = self::SCHEME_WATCHOS; $schemes[] = self::SCHEME_MACOS; + $schemes[] = self::SCHEME_TVOS; + $schemes[] = self::SCHEME_IOS; break; - case self::TYPE_FLUTTER_WINDOWS: - case self::TYPE_UNITY: + case self::TYPE_WINDOWS: $schemes[] = self::SCHEME_WINDOWS; break; - case self::TYPE_FLUTTER_LINUX: + case self::TYPE_LINUX: $schemes[] = self::SCHEME_LINUX; break; - case self::TYPE_APPLE_WATCHOS: - $schemes[] = self::SCHEME_WATCHOS; - break; - case self::TYPE_APPLE_TVOS: - $schemes[] = self::SCHEME_TVOS; - break; default: break; } diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 6d9cd5412f..99ec9e65d2 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -145,9 +145,20 @@ class Server $paths = $this->paths; $state = $this->state; - Http::setResource('installerState', fn () => $state); - Http::setResource('installerConfig', fn () => $config); - Http::setResource('installerPaths', fn () => $paths); + $adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter { + public function getNativeServer(): SwooleServer + { + return $this->server; + } + }; + + $nativeServer = $adapter->getNativeServer(); + + $container = $adapter->getContainer(); + $container->set('installerState', fn () => $state); + $container->set('installerConfig', fn () => $config); + $container->set('installerPaths', fn () => $paths); + $container->set('swooleServer', fn () => $nativeServer); // Register routes via Utopia Platform $platform = new Installer(); @@ -160,17 +171,6 @@ class Server ->inject('response') ->action($errorHandler->action(...)); - $adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter { - public function getNativeServer(): SwooleServer - { - return $this->server; - } - }; - - $nativeServer = $adapter->getNativeServer(); - - Http::setResource('swooleServer', fn () => $nativeServer); - $nativeServer->on('start', function () use ($nativeServer, $port, $readyFile) { \Swoole\Process::signal(SIGTERM, fn () => $nativeServer->shutdown()); \Swoole\Process::signal(SIGINT, fn () => $nativeServer->shutdown()); @@ -180,7 +180,7 @@ class Server } }); - $adapter->onRequest(function (Request $request, Response $response) use ($files) { + $adapter->onRequest(function (Request $request, Response $response) use ($adapter, $files) { // Serve static files from memory $uri = $request->getURI(); if ($files->isFileLoaded($uri)) { @@ -190,7 +190,7 @@ class Server return; } - $app = new Http('UTC'); + $app = new Http($adapter, 'UTC'); $app->run($request, $response); }); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php index 3585bc4477..3d07c65250 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php @@ -61,16 +61,16 @@ class Create extends Action $databaseKeys = System::getEnv('_APP_DATABASE_DOCUMENTSDB_KEYS', ''); $databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'); - $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); - $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); + $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))); + $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''))); break; case VECTORSDB: $databases = Config::getParam('pools-vectorsdb', []); $databaseKeys = System::getEnv('_APP_DATABASE_VECTORSDB_KEYS', ''); $databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'); - $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); - $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); + $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))); + $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''))); break; default: // legacy/tablesdb @@ -108,7 +108,7 @@ class Create extends Action if ($index !== false) { $selectedDsn = $databases[$index]; } else { - if (!empty($dsn)) { + if (!empty($dsn) && !empty($databaseSharedTables)) { $beforeFilter = \array_values($databases); if ($isSharedTablesV1) { $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1)); @@ -118,7 +118,10 @@ class Create extends Action $databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables)); } } - $selectedDsn = !empty($databases) ? $databases[array_rand($databases)] : ''; + if (empty($databases)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, "No {$databasetype} database pool available for the current shared-tables mode"); + } + $selectedDsn = $databases[array_rand($databases)]; } if (\in_array($selectedDsn, $databaseSharedTables)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index 0c8c6a8520..c4d51e6c64 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -182,19 +182,33 @@ class Update extends Action $dbForDatabases = $getDatabasesDB($databaseDoc); try { - $dbForDatabases->withTransaction(function () use ($dbForDatabases, $dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $authorization) { - $authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([ - 'status' => 'committing', - ]))); + $transaction = $authorization->skip(fn () => $dbForProject->updateDocument( + 'transactions', + $transactionId, + new Document(['status' => 'committing']) + )); - $operations = $authorization->skip(fn () => $dbForProject->find('transactionLogs', [ - Query::equal('transactionInternalId', [$transaction->getSequence()]), - Query::orderAsc(), - Query::limit(PHP_INT_MAX), - ])); + $operations = $authorization->skip(fn () => $dbForProject->find('transactionLogs', [ + Query::equal('transactionInternalId', [$transaction->getSequence()]), + Query::orderAsc(), + Query::limit(PHP_INT_MAX), + ])); + $collections = []; + foreach ($operations as $operation) { + $databaseInternalId = $operation['databaseInternalId']; + $collectionInternalId = $operation['collectionInternalId']; + $collectionId = "database_{$databaseInternalId}_collection_{$collectionInternalId}"; + + if (!isset($collections[$collectionId])) { + $collections[$collectionId] = $authorization->skip( + fn () => $dbForProject->getCollection($collectionId) + ); + } + } + + $dbForDatabases->withTransaction(function () use ($dbForDatabases, $transactionState, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $collections) { $state = []; - $collections = []; foreach ($operations as $operation) { $databaseInternalId = $operation['databaseInternalId']; @@ -210,11 +224,6 @@ class Update extends Action $data = $data->getArrayCopy(); } - if (!isset($collections[$collectionId])) { - $collections[$collectionId] = $authorization->skip( - fn () => $dbForProject->getCollection($collectionId) - ); - } $collection = $collections[$collectionId]; if (\is_array($data) && !empty($data)) { @@ -276,16 +285,17 @@ class Update extends Action } } - $transaction = $authorization->skip(fn () => $dbForProject->updateDocument( - 'transactions', - $transactionId, - new Document(['status' => 'committed']) - )); - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($transaction); }); + + $transaction = $authorization->skip(fn () => $dbForProject->updateDocument( + 'transactions', + $transactionId, + new Document(['status' => 'committed']) + )); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($transaction); } catch (NotFoundException $e) { $authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([ 'status' => 'failed', diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php similarity index 60% rename from src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index de11bb0091..24d1c48cf1 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -1,20 +1,15 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) - ->setHttpPath('/v1/projects/:projectId/labels') + ->setHttpPath('/v1/project/labels') + ->httpAlias('/v1/projects/:projectId/labels') ->desc('Update project labels') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'labels.*.update') + ->label('audits.event', 'project.labels.update') + ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', + namespace: 'project', + group: null, name: 'updateLabels', description: <<param('projectId', '', new UID(), 'Project unique ID.') ->param('labels', [], new ArrayList(new Text(36, allowList: [...Text::NUMBERS, ...Text::ALPHABET_UPPER, ...Text::ALPHABET_LOWER]), APP_LIMIT_ARRAY_LABELS_SIZE), 'Array of project labels. Replaces the previous labels. Maximum of ' . APP_LIMIT_ARRAY_LABELS_SIZE . ' labels are allowed, each up to 36 alphanumeric characters long.') ->inject('response') ->inject('dbForPlatform') + ->inject('project') ->callback($this->action(...)); } @@ -67,17 +60,11 @@ class Update extends Action * @param array $labels */ public function action( - string $projectId, array $labels, Response $response, - Database $dbForPlatform + Database $dbForPlatform, + Document $project ): void { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - $labels = (array) \array_values(\array_unique($labels)); $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php new file mode 100644 index 0000000000..e33e531017 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/android') + ->desc('Create project Android platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createAndroidPlatform', + description: <<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('applicationId', '', new Text(256), 'Android application ID. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $applicationId, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_ANDROID, + 'name' => $name, + 'key' => $applicationId, + 'hostname' => '', + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_ANDROID); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php new file mode 100644 index 0000000000..cd12f2da74 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/android/:platformId') + ->desc('Update project Android platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateAndroidPlatform', + description: <<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('applicationId', '', new Text(256), 'Android application ID. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $applicationId, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_ANDROID) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $applicationId, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_ANDROID); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php new file mode 100644 index 0000000000..4054face8e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/apple') + ->desc('Create project Apple platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createApplePlatform', + description: <<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('bundleIdentifier', '', new Text(256), 'Apple bundle identifier. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $bundleIdentifier, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_APPLE, + 'name' => $name, + 'key' => $bundleIdentifier, + 'hostname' => '', + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_APPLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php new file mode 100644 index 0000000000..95d67be26c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/apple/:platformId') + ->desc('Update project Apple platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateApplePlatform', + description: <<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('bundleIdentifier', '', new Text(256), 'Apple bundle identifier. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $bundleIdentifier, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_APPLE) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $bundleIdentifier, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_APPLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php new file mode 100644 index 0000000000..907046d27e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -0,0 +1,89 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/platforms/:platformId') + ->httpAlias('/v1/projects/:projectId/platforms/:platformId') + ->desc('Delete project platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].delete') + ->label('audits.event', 'project.platform.delete') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'deletePlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Event $queueForEvents, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('platforms', $platform->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php new file mode 100644 index 0000000000..c5f4b8fc81 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/platforms/:platformId') + ->httpAlias('/v1/projects/:projectId/platforms/:platformId') + ->desc('Get project platform') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'getPlatform', + description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + Document $project + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + $type = $platform->getAttribute('type'); + + $model = match($type) { + Platform::TYPE_WEB => Response::MODEL_PLATFORM_WEB, + Platform::TYPE_APPLE => Response::MODEL_PLATFORM_APPLE, + Platform::TYPE_ANDROID => Response::MODEL_PLATFORM_ANDROID, + Platform::TYPE_WINDOWS => Response::MODEL_PLATFORM_WINDOWS, + Platform::TYPE_LINUX => Response::MODEL_PLATFORM_LINUX, + default => Response::MODEL_PLATFORM_WEB // Backwards compatibility + }; + + $response->dynamic($platform, $model); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php new file mode 100644 index 0000000000..ae568740b8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/linux') + ->desc('Create project Linux platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createLinuxPlatform', + description: <<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('packageName', '', new Text(256), 'Linux package name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageName, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_LINUX, + 'name' => $name, + 'key' => $packageName, + 'hostname' => '', // Web platform attribute + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_LINUX); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php new file mode 100644 index 0000000000..92674d2276 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/linux/:platformId') + ->desc('Update project Linux platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateLinuxPlatform', + description: <<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('packageName', '', new Text(256), 'Linux package name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageName, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_LINUX) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $packageName, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_LINUX); + } +} 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 new file mode 100644 index 0000000000..f16c0af3fa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -0,0 +1,173 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/web') + ->httpAlias('/v1/projects/:projectId/platforms') + ->desc('Create project web platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createWebPlatform', + description: <<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('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') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $hostname, + ?string $key, // For backwards compatibility + ?string $type, // For backwards compatibility + Request $request, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $key = $key ?? ''; // App platform attribute, backwards compatibility + $type = $type ?? ''; // App platform attribute, backwards compatibility + + // Backwards compatibility + // Used to have: type, name, key, hostname + if (!empty($type)) { + // Validate deprecated type, and rename to new type + $deprecatedTypeMapping = [ + // Web + 'web' => Platform::TYPE_WEB, + 'flutter-web' => Platform::TYPE_WEB, + 'unity' => Platform::TYPE_WEB, // Was not officially supported anyway + + // Apple + 'flutter-macos' => Platform::TYPE_APPLE, + 'flutter-ios' => Platform::TYPE_APPLE, + 'react-native-ios' => Platform::TYPE_APPLE, + 'apple-ios' => Platform::TYPE_APPLE, + 'apple-macos' => Platform::TYPE_APPLE, + 'apple-watchos' => Platform::TYPE_APPLE, + 'apple-tvos' => Platform::TYPE_APPLE, + + // Android + 'flutter-android' => Platform::TYPE_ANDROID, + 'android' => Platform::TYPE_ANDROID, + 'react-native-android' => Platform::TYPE_ANDROID, + + 'flutter-linux' => Platform::TYPE_LINUX, + 'flutter-windows' => Platform::TYPE_WINDOWS, + ]; + + $typeValidator = new WhiteList(\array_keys($deprecatedTypeMapping)); + if (!$typeValidator->isValid($request->getParam('type', ''))) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "type" is invalid: ' . $typeValidator->getDescription()); + } + + $type = $deprecatedTypeMapping[$request->getParam('type', '')] ?? ''; + } + + if (!empty($key)) { + // Validate deprecated app id (key) + $keyValidator = new Text(256); + if (!$keyValidator->isValid($key)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "key" is invalid: ' . $keyValidator->getDescription()); + } + } + + if (empty($key) && empty($type)) { + // Modern request, validate hostname + if (empty($hostname)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.'); + } + } + + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => $type ?: Platform::TYPE_WEB, // Preserve type for backwards compatibility + 'name' => $name, + 'key' => $key, + 'hostname' => $hostname + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_WEB); + } +} 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 new file mode 100644 index 0000000000..3677466452 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -0,0 +1,151 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/web/:platformId') + ->httpAlias('/v1/projects/:projectId/platforms/:platformId') + ->desc('Update project web platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateWebPlatform', + description: <<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('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') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $hostname, + ?string $key, // For backwards compatibility + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $key = $key ?? ''; // App platform attribute, backwards compatibility + + // Backwards compatibility + // Used to have: type, name, key, hostname + if (!empty($key)) { + // Validate deprecated app id (key) + $keyValidator = new Text(256); + if (!$keyValidator->isValid($key)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "key" is invalid: ' . $keyValidator->getDescription()); + } + } + + // One day, ideally, we ensure hostname is not empty + // But for backwards compatibility backend must threat it as optional for now + + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + // Wrapped in if, for backwards compatibility + if (!empty($hostname)) { + $supportedTypes = [ + Platform::TYPE_WEB, + // Backwards compatibility + 'flutter-web', + 'unity', + 'flutter-macos', + 'flutter-ios', + 'react-native-ios', + 'apple-ios', + 'apple-macos', + 'apple-watchos', + 'apple-tvos', + 'flutter-android', + 'react-native-android', + 'flutter-windows', + 'flutter-linux', + ]; + if (!in_array($platform->getAttribute('type', ''), $supportedTypes)) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + } + + $updates = new Document([ + 'name' => $name, + ]); + + // Wrapped in if, for backwards compatibility + if (!empty($hostname)) { + $updates->setAttribute('hostname', $hostname); + } + + // Backwards compatibility + if (!empty($key)) { + $updates->setAttribute('key', $key); + } + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_WEB); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php new file mode 100644 index 0000000000..a7e583cadb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/platforms/windows') + ->desc('Create project Windows platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].create') + ->label('audits.event', 'project.platform.create') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'createWindowsPlatform', + description: <<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('packageIdentifierName', '', new Text(256), 'Windows package identifier name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageIdentifierName, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId; + + $platform = new Document([ + '$id' => $platformId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'type' => Platform::TYPE_WINDOWS, + 'name' => $name, + 'key' => $packageIdentifierName, + 'hostname' => '', + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform)); + } catch (DuplicateException) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($platform, Response::MODEL_PLATFORM_WINDOWS); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php new file mode 100644 index 0000000000..43d6c65d44 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/platforms/windows/:platformId') + ->desc('Update project Windows platform') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'platforms.[platformId].update') + ->label('audits.event', 'project.platform.update') + ->label('audits.resource', 'project.platform/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'updateWindowsPlatform', + description: <<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('packageIdentifierName', '', new Text(256), 'Windows package identifier name. Max length: 256 chars.') + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $platformId, + string $name, + string $packageIdentifierName, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId)); + + if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::PLATFORM_NOT_FOUND); + } + + if ($platform->getAttribute('type', '') !== Platform::TYPE_WINDOWS) { + throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED); + } + + $updates = new Document([ + 'name' => $name, + 'key' => $packageIdentifierName, + ]); + + try { + $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::PLATFORM_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('platformId', $platform->getId()); + + $response->dynamic($platform, Response::MODEL_PLATFORM_WINDOWS); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php new file mode 100644 index 0000000000..14a67418ee --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php @@ -0,0 +1,125 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/platforms') + ->httpAlias('/v1/projects/:projectId/platforms') + ->desc('List project platforms') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'platforms', + name: 'listPlatforms', + description: <<param('queries', [], new Platforms(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Platforms::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + foreach ($queries as $query) { + if (\in_array($query->getAttribute(), ['bundleIdentifier', 'applicationId', 'packageIdentifierName', 'packageName'])) { + $query->setAttribute('key'); + } + } + + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $platformId = $cursor->getValue(); + $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('platforms', [ + Query::equal('$id', [$platformId]), + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Platform '{$platformId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $platforms = $authorization->skip(fn () => $dbForPlatform->find('platforms', $queries)); + $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('platforms', $filterQueries, APP_LIMIT_COUNT)) : 0; + } catch (OrderException $e) { + 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."); + } + + $response->dynamic(new Document([ + 'platforms' => $platforms, + 'total' => $total, + ]), Response::MODEL_PLATFORM_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php index acc39bb68d..8dbc720045 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -19,7 +18,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -class Create extends Base +class Create extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index ac47ec3dbb..131cf7245b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -16,7 +15,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Delete extends Base +class Delete extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php index 6de51dacaf..af14148c92 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -13,7 +12,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Get extends Base +class Get extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 61a943b618..988a7c0849 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -19,7 +18,7 @@ use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; use Utopia\Validator\Text; -class Update extends Base +class Update extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php index cd11fe68c6..bd391ea3b4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -19,7 +18,7 @@ use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; -class XList extends Base +class XList extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 949fb2bcd9..01fe2fcc04 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,6 +3,20 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Update as UpdateApplePlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Delete as DeletePlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Get as GetPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Linux\Create as CreateLinuxPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Linux\Update as UpdateLinuxPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Create as CreateWebPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as UpdateWebPlatform; +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\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; @@ -20,10 +34,28 @@ class Http extends Service $this->addAction(Init::getName(), new Init()); // Project + $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); $this->addAction(GetVariable::getName(), new GetVariable()); $this->addAction(DeleteVariable::getName(), new DeleteVariable()); $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + + // Platforms + $this->addAction(DeletePlatform::getName(), new DeletePlatform()); + $this->addAction(UpdateWebPlatform::getName(), new UpdateWebPlatform()); + $this->addAction(UpdateApplePlatform::getName(), new UpdateApplePlatform()); + $this->addAction(UpdateAndroidPlatform::getName(), new UpdateAndroidPlatform()); + $this->addAction(UpdateWindowsPlatform::getName(), new UpdateWindowsPlatform()); + $this->addAction(UpdateLinuxPlatform::getName(), new UpdateLinuxPlatform()); + $this->addAction(CreateWebPlatform::getName(), new CreateWebPlatform()); + $this->addAction(CreateApplePlatform::getName(), new CreateApplePlatform()); + $this->addAction(CreateAndroidPlatform::getName(), new CreateAndroidPlatform()); + $this->addAction(CreateWindowsPlatform::getName(), new CreateWindowsPlatform()); + $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform()); + $this->addAction(GetPlatform::getName(), new GetPlatform()); + $this->addAction(ListPlatforms::getName(), new ListPlatforms()); } } diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 8b0d6f87c8..8275e664d5 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -8,7 +8,6 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject; -use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels; use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; @@ -31,7 +30,6 @@ class Http extends Service $this->addAction(CreateProject::getName(), new CreateProject()); $this->addAction(UpdateProject::getName(), new UpdateProject()); $this->addAction(ListProjects::getName(), new ListProjects()); - $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); $this->addAction(CreateSchedule::getName(), new CreateSchedule()); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index 91daf33b2b..3c716202af 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Event\Event as QueueEvent; use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -25,7 +24,7 @@ use Utopia\Validator\Multiple; use Utopia\Validator\Text; use Utopia\Validator\URL; -class Create extends Base +class Create extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php index 7730e9fc2c..cd05b6210c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -18,7 +17,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Delete extends Base +class Delete extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index 52ac455fc9..ebe6fa7bcb 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -16,7 +15,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Get extends Base +class Get extends Action { use HTTP; 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 9b2612863f..51c5bfbaf9 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -17,7 +16,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -class Update extends Base +class Update extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index a1387c356c..968c15dae2 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Event\Event as QueueEvent; use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -24,7 +23,7 @@ use Utopia\Validator\Multiple; use Utopia\Validator\Text; use Utopia\Validator\URL; -class Update extends Base +class Update extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index fae95d7c5d..2a4c4f9e59 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -20,7 +19,7 @@ use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; -class XList extends Base +class XList extends Action { use HTTP; diff --git a/src/Appwrite/Platform/Tasks/Migrate.php b/src/Appwrite/Platform/Tasks/Migrate.php index b952808998..6f9f92435a 100644 --- a/src/Appwrite/Platform/Tasks/Migrate.php +++ b/src/Appwrite/Platform/Tasks/Migrate.php @@ -9,7 +9,6 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Validator\Authorization; -use Utopia\Http\Http; use Utopia\Platform\Action; use Utopia\Registry\Registry; use Utopia\Validator\Text; @@ -32,6 +31,7 @@ class Migrate extends Action ->inject('getProjectDB') ->inject('register') ->inject('authorization') + ->inject('console') ->callback($this->action(...)); } @@ -48,7 +48,8 @@ class Migrate extends Action Database $dbForPlatform, callable $getProjectDB, Registry $register, - Authorization $authorization + Authorization $authorization, + Document $console ): void { if (!\array_key_exists($version, Migration::$versions)) { @@ -125,8 +126,6 @@ class Migrate extends Action Console::log('Migrated ' . ++$count . '/' . $total . ' projects...'); }); - $console = (new Http('UTC'))->getResource('console'); - try { $migration ->setProject($console, $getProjectDB($console), $dbForPlatform, $authorization, $getProjectDB); diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index e8a69afddb..4725f4095f 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -639,29 +639,15 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND } catch (\Throwable) { } - // Checkout dev branch (or create if it doesn't exist) + // 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', '-f', $gitBranch); + $repo->execute('checkout', '-B', $gitBranch, $repoBranch); } catch (\Throwable) { $repo->execute('checkout', '-b', $gitBranch); } - // Fetch dev branch, or push to create it on remote - try { - $repo->execute('fetch', 'origin', $gitBranch, '--quiet', '--no-tags', '--depth', '1'); - } catch (\Throwable) { - try { - $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); - } catch (\Throwable) { - } - } - - // Sync with remote dev branch - try { - $repo->execute('reset', '--hard', "origin/{$gitBranch}"); - } catch (\Throwable) { - } - // Backup .github before cleaning working tree $githubDir = $target . '/.github'; $githubBackup = \sys_get_temp_dir() . '/.github-backup-' . \getmypid(); @@ -699,7 +685,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND return true; } - $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); + $repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet'); } catch (\Throwable $e) { Console::warning(" Git push failed: " . $e->getMessage()); return false; diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index a6a5284fb0..0953610a69 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Tasks; +use Appwrite\Network\Validator\Redirect; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Specification\Format\OpenAPI3; @@ -18,6 +19,9 @@ use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\DI\Container; +use Utopia\Http\Adapter\FPM\Server as FPMServer; use Utopia\Http\Http; use Utopia\Http\Request as UtopiaRequest; use Utopia\Http\Response as UtopiaResponse; @@ -336,11 +340,17 @@ class Specs extends Action $mocks = ($mode === 'mocks'); - // Mock dependencies - Http::setResource('request', fn () => $this->getRequest()); - Http::setResource('response', fn () => $response); - Http::setResource('dbForPlatform', fn () => new Database(new MySQL(''), new Cache(new None()))); - Http::setResource('dbForProject', fn () => new Database(new MySQL(''), new Cache(new None()))); + // Mock dependencies needed by param validator injections in route definitions + $specsContainer = new Container(); + $specsContainer->set('request', fn () => $this->getRequest()); + $specsContainer->set('response', fn () => $response); + $specsContainer->set('dbForPlatform', fn () => new Database(new MySQL(''), new Cache(new None()))); + $specsContainer->set('dbForProject', fn () => new Database(new MySQL(''), new Cache(new None()))); + $specsContainer->set('redirectValidator', fn () => new Redirect([], [])); + $specsContainer->set('project', fn () => new Document([])); + $specsContainer->set('passwordsDictionary', fn () => []); + $specsContainer->set('localeCodes', fn () => \array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', []))); + $specsContainer->set('plan', fn () => []); $platforms = static::getPlatforms(); $authCounts = $this->getAuthCounts(); @@ -438,7 +448,7 @@ class Specs extends Action } $arguments = [ - new Http('UTC'), + new Http(new FPMServer($specsContainer), 'UTC'), $services, $routes, $models, @@ -472,7 +482,12 @@ class Specs extends Action ? $specsDir . '/' . $format . '-mocks-' . $platform . '.json' : $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json'; - $parsedSpecs = $specs->parse(); + try { + $parsedSpecs = $specs->parse(); + } catch (\RuntimeException $e) { + throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); + } + $encodedSpecs = \json_encode($parsedSpecs, JSON_PRETTY_PRINT); unset($parsedSpecs); diff --git a/src/Appwrite/Promises/Swoole.php b/src/Appwrite/Promises/Swoole.php index c258ef6a5e..9c06fbda2f 100644 --- a/src/Appwrite/Promises/Swoole.php +++ b/src/Appwrite/Promises/Swoole.php @@ -2,10 +2,14 @@ namespace Appwrite\Promises; +use Swoole\Coroutine; use Swoole\Coroutine\Channel; +use Utopia\DI\Container; class Swoole extends Promise { + private const REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container'; + public function __construct(?callable $executor = null) { parent::__construct($executor); @@ -16,7 +20,14 @@ class Swoole extends Promise callable $resolve, callable $reject ): void { - \go(function () use ($executor, $resolve, $reject) { + $parentContainer = (Coroutine::getCid() !== -1) + ? (Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? null) + : null; + + \go(function () use ($executor, $resolve, $reject, $parentContainer) { + if ($parentContainer !== null) { + Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = new Container($parentContainer); + } try { $executor($resolve, $reject); } catch (\Throwable $exception) { diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index dd4d378345..628f0f2f8f 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -769,6 +769,9 @@ abstract class Format protected function getNestedModels(Model $model, array &$usedModels): void { foreach ($model->getRules() as $rule) { + if (($rule['hidden'] ?? false) === true) { + continue; + } if (!in_array($model->getType(), $usedModels)) { continue; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 88f577eac6..94e0f831b7 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -278,6 +278,18 @@ class OpenAPI3 extends Format } } + if (\is_string($model)) { + throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + + if (\is_array($model)) { + foreach ($model as $m) { + if (\is_string($m)) { + throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + } + } + if (!(\is_array($model)) && $model->isNone()) { $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => in_array($produces, [ @@ -821,6 +833,10 @@ class OpenAPI3 extends Format } foreach ($model->getRules() as $name => $rule) { + if (($rule['hidden'] ?? false) === true) { + continue; + } + $type = ''; $format = null; $items = null; diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index f9c79431f0..14a18eea2e 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -285,6 +285,18 @@ class Swagger2 extends Format } } + if (\is_string($model)) { + throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + + if (\is_array($model)) { + foreach ($model as $m) { + if (\is_string($m)) { + throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + } + } + if (!(\is_array($model)) && $model->isNone()) { $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => in_array($produces, [ @@ -801,6 +813,10 @@ class Swagger2 extends Format } foreach ($model->getRules() as $name => $rule) { + if (($rule['hidden'] ?? false) === true) { + continue; + } + $type = ''; $format = null; $items = null; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Platforms.php b/src/Appwrite/Utopia/Database/Validator/Queries/Platforms.php new file mode 100644 index 0000000000..525c832f8d --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Platforms.php @@ -0,0 +1,25 @@ +fillPlatformId($content); + $content = $this->removePlatformStore($content); + // Keep 'key' for backwards compatibility + break; + case 'project.updateWebPlatform': + $content = $this->removePlatformStore($content); + // Keep 'key' for backwards compatibility + break; + case 'project.createApplePlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'bundleIdentifier'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateApplePlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'bundleIdentifier'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.createAndroidPlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'applicationId'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateAndroidPlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'applicationId'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.createWindowsPlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageIdentifierName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateWindowsPlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageIdentifierName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.createLinuxPlatform': + $content = $this->fillPlatformId($content); + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.updateLinuxPlatform': + $content = $this->removePlatformStore($content); + $content = $this->replacePlatformKey($content, 'packageName'); + unset($content['hostname']); // Hostname unsupported + break; + case 'project.listPlatforms': + $content = $this->preservePlatformsQueries($content); + break; case 'webhooks.create': $content = $this->fillWebhookid($content); break; @@ -79,4 +136,33 @@ class V21 extends Filter return $content; } + + protected function fillPlatformId(array $content): array + { + $content['platformId'] = $content['platformId'] ?? 'unique()'; + return $content; + } + + protected function replacePlatformKey(array $content, string $newKey): array + { + $content[$newKey] = $content[$newKey] ?? $content['key'] ?? null; + unset($content['key']); + + return $content; + } + + protected function removePlatformStore(array $content): array + { + unset($content['store']); + return $content; + } + + protected function preservePlatformsQueries(array $content): array + { + $content['queries'] = $content['queries'] ?? [ + Query::limit(5000) + ]; + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 9d0e8abefa..295348c665 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -256,7 +256,11 @@ class Response extends SwooleResponse public const MODEL_MOCK_NUMBER = 'mockNumber'; public const MODEL_AUTH_PROVIDER = 'authProvider'; public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList'; - public const MODEL_PLATFORM = 'platform'; + public const MODEL_PLATFORM_APPLE = 'platformApple'; + public const MODEL_PLATFORM_ANDROID = 'platformAndroid'; + public const MODEL_PLATFORM_WINDOWS = 'platformWindows'; + public const MODEL_PLATFORM_LINUX = 'platformLinux'; + public const MODEL_PLATFORM_WEB = 'platformWeb'; public const MODEL_PLATFORM_LIST = 'platformList'; public const MODEL_VARIABLE = 'variable'; public const MODEL_VARIABLE_LIST = 'variableList'; @@ -476,7 +480,13 @@ class Response extends SwooleResponse foreach ($rule['type'] as $type) { $condition = false; foreach ($this->getModel($type)->conditions as $attribute => $val) { - $condition = $item->getAttribute($attribute) === $val; + + if (\is_array($val)) { + $condition = \in_array($item->getAttribute($attribute), $val); + } else { + $condition = $item->getAttribute($attribute) === $val; + } + if (!$condition) { break; } diff --git a/src/Appwrite/Utopia/Response/Filters/V21.php b/src/Appwrite/Utopia/Response/Filters/V21.php index 3fc16d6c8a..128662a409 100644 --- a/src/Appwrite/Utopia/Response/Filters/V21.php +++ b/src/Appwrite/Utopia/Response/Filters/V21.php @@ -11,6 +11,23 @@ class V21 extends Filter public function parse(array $content, string $model): array { return match ($model) { + // Web is special case, it has backwards compatibility + Response::MODEL_PLATFORM_WEB => $this->parsePlatform($content), + Response::MODEL_PLATFORM_APPLE => $this->parsePlatform($content), + Response::MODEL_PLATFORM_ANDROID => $this->parsePlatform($content), + Response::MODEL_PLATFORM_WINDOWS => $this->parsePlatform($content), + Response::MODEL_PLATFORM_LINUX => $this->parsePlatform($content), + Response::MODEL_PLATFORM_LIST => $this->handleList( + $content, + "platforms", + fn ($item) => $this->parsePlatform($item), + ), + Response::MODEL_PROJECT => $this->parseProjectForPlatform($content), + Response::MODEL_PROJECT_LIST => $this->handleList( + $content, + "projects", + fn ($item) => $this->parseProjectForPlatform($item), + ), Response::MODEL_USER => $this->parseUser($content), Response::MODEL_USER_LIST => $this->handleList( $content, @@ -107,4 +124,34 @@ class V21 extends Filter return $content; } + + protected function parseProjectForPlatform(array $content): array + { + // Parse platforms under project, since it's a subquery + $content['platforms'] = \array_map(fn ($item) => $this->parsePlatform($item), $content['platforms']); + return $content; + } + + protected function parsePlatform(array $content): array + { + // Map platform-specific identifier fields back to 'key' + $content['key'] = + ($content['bundleIdentifier'] ?? '') + ?: ($content['applicationId'] ?? '') + ?: ($content['packageIdentifierName'] ?? '') + ?: ($content['packageName'] ?? '') + ?: ($content['key'] ?? '') + ?: ''; + + unset($content['bundleIdentifier']); + unset($content['applicationId']); + unset($content['packageIdentifierName']); + unset($content['packageName']); + + // Restore fields removed in v1.9 + $content['store'] = $content['store'] ?? ''; + $content['hostname'] = $content['hostname'] ?? ''; + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response/Model/Platform.php b/src/Appwrite/Utopia/Response/Model/Platform.php deleted file mode 100644 index 151e43780d..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Platform.php +++ /dev/null @@ -1,100 +0,0 @@ -addRule('$id', [ - 'type' => self::TYPE_STRING, - 'description' => 'Platform ID.', - 'default' => '', - 'example' => '5e5ea5c16897e', - ]) - ->addRule('$createdAt', [ - 'type' => self::TYPE_DATETIME, - 'description' => 'Platform creation date in ISO 8601 format.', - 'default' => '', - 'example' => self::TYPE_DATETIME_EXAMPLE, - ]) - ->addRule('$updatedAt', [ - 'type' => self::TYPE_DATETIME, - 'description' => 'Platform update date in ISO 8601 format.', - 'default' => '', - 'example' => self::TYPE_DATETIME_EXAMPLE, - ]) - ->addRule('name', [ - 'type' => self::TYPE_STRING, - 'description' => 'Platform name.', - 'default' => '', - 'example' => 'My Web App', - ]) - ->addRule('type', [ - 'type' => self::TYPE_ENUM, - 'description' => 'Platform type. Possible values are: web, flutter-web, flutter-ios, flutter-android, flutter-linux, flutter-macos, flutter-windows, apple-ios, apple-macos, apple-watchos, apple-tvos, android, unity, react-native-ios, react-native-android.', - 'default' => '', - 'example' => 'web', - 'enum' => ['web', 'flutter-web', 'flutter-ios', 'flutter-android', 'flutter-linux', 'flutter-macos', 'flutter-windows', 'apple-ios', 'apple-macos', 'apple-watchos', 'apple-tvos', 'android', 'unity', 'react-native-ios', 'react-native-android'], - ]) - ->addRule('key', [ - 'type' => self::TYPE_STRING, - 'description' => 'Platform Key. iOS bundle ID or Android package name. Empty string for other platforms.', - 'default' => '', - 'example' => 'com.company.appname', - ]) - ->addRule('store', [ - 'type' => self::TYPE_STRING, - 'description' => 'App store or Google Play store ID.', - 'example' => '', - ]) - ->addRule('hostname', [ - 'type' => self::TYPE_STRING, - 'description' => 'Web app hostname. Empty string for other platforms.', - 'default' => '', - 'example' => 'app.example.com', - ]) - ->addRule('httpUser', [ - 'type' => self::TYPE_STRING, - 'description' => 'HTTP basic authentication username.', - 'default' => '', - 'example' => 'username', - ]) - ->addRule('httpPass', [ - 'type' => self::TYPE_STRING, - 'description' => 'HTTP basic authentication password.', - 'default' => '', - 'example' => 'password', - ]) - ; - } - - /** - * Get Name - * - * @return string - */ - public function getName(): string - { - return 'Platform'; - } - - /** - * Get Type - * - * @return string - */ - public function getType(): string - { - return Response::MODEL_PLATFORM; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformAndroid.php b/src/Appwrite/Utopia/Response/Model/PlatformAndroid.php new file mode 100644 index 0000000000..007cffedde --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformAndroid.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_ANDROID, + ]; + + parent::__construct(); + + $this + ->addRule('applicationId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Android application ID.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Android'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_ANDROID; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'applicationId' + $document->setAttribute('applicationId', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformApple.php b/src/Appwrite/Utopia/Response/Model/PlatformApple.php new file mode 100644 index 0000000000..b9154e659d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformApple.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_APPLE, + ]; + + parent::__construct(); + + $this + ->addRule('bundleIdentifier', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple bundle identifier.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Apple'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_APPLE; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'bundleIdentifier' + $document->setAttribute('bundleIdentifier', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformBase.php b/src/Appwrite/Utopia/Response/Model/PlatformBase.php new file mode 100644 index 0000000000..1b7ec75e6d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformBase.php @@ -0,0 +1,57 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Platform ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Platform creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Platform update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('name', [ + 'type' => self::TYPE_STRING, + 'description' => 'Platform name.', + 'default' => '', + 'example' => 'My Web App', + ]) + ->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Platform type. Possible values are: ' . implode(', ', self::getSupportedTypes()) . '.', + 'default' => '', + 'example' => Platform::TYPE_WEB, + 'enum' => self::getSupportedTypes(), + ]) + ; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformLinux.php b/src/Appwrite/Utopia/Response/Model/PlatformLinux.php new file mode 100644 index 0000000000..66bc679b37 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformLinux.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_LINUX, + ]; + + parent::__construct(); + + $this + ->addRule('packageName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Linux package name.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Linux'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_LINUX; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'packageName' + $document->setAttribute('packageName', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformList.php b/src/Appwrite/Utopia/Response/Model/PlatformList.php new file mode 100644 index 0000000000..7ad7ffed48 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformList.php @@ -0,0 +1,53 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of platforms in the given project.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('platforms', [ + 'type' => [ + Response::MODEL_PLATFORM_WEB, + Response::MODEL_PLATFORM_APPLE, + Response::MODEL_PLATFORM_ANDROID, + Response::MODEL_PLATFORM_WINDOWS, + Response::MODEL_PLATFORM_LINUX, + ], + 'description' => 'List of platforms.', + 'default' => [], + 'array' => true + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platforms List'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformWeb.php b/src/Appwrite/Utopia/Response/Model/PlatformWeb.php new file mode 100644 index 0000000000..af03194fdb --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformWeb.php @@ -0,0 +1,71 @@ +conditions = [ + 'type' => [ + Platform::TYPE_WEB, + // Backwards compatibility + 'flutter-web', + 'unity', + 'flutter-macos', + 'flutter-ios', + 'react-native-ios', + 'apple-ios', + 'apple-macos', + 'apple-watchos', + 'apple-tvos', + 'flutter-android', + 'react-native-android', + 'flutter-windows', + 'flutter-linux', + ], + ]; + + parent::__construct(); + + $this + ->addRule('hostname', [ + 'type' => self::TYPE_STRING, + 'description' => 'Web app hostname. Empty string for other platforms.', + 'default' => '', + 'example' => 'app.example.com', + ]) + // Backwards compatibility + ->addRule('key', [ + 'hidden' => true, + 'type' => self::TYPE_STRING, + 'description' => 'Deprecated for old versions using alias endpoint to create universal platform.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Web'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_WEB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PlatformWindows.php b/src/Appwrite/Utopia/Response/Model/PlatformWindows.php new file mode 100644 index 0000000000..20da977468 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PlatformWindows.php @@ -0,0 +1,58 @@ +conditions = [ + 'type' => Platform::TYPE_WINDOWS, + ]; + + parent::__construct(); + + $this + ->addRule('packageIdentifierName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Windows package identifier name.', + 'default' => '', + 'example' => 'com.company.appname', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Platform Windows'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PLATFORM_WINDOWS; + } + + public function filter(Document $document): Document + { + // DB level: 'key' + // API level: 'packageIdentifierName' + $document->setAttribute('packageIdentifierName', $document->getAttribute('key', null)); + $document->removeAttribute('key'); + + return $document; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 5902902e9e..1ef73aa769 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -9,11 +9,6 @@ use Utopia\Database\Document; class Project extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this @@ -200,7 +195,13 @@ class Project extends Model 'array' => true, ]) ->addRule('platforms', [ - 'type' => Response::MODEL_PLATFORM, + 'type' => [ + Response::MODEL_PLATFORM_WEB, + Response::MODEL_PLATFORM_APPLE, + Response::MODEL_PLATFORM_ANDROID, + Response::MODEL_PLATFORM_WINDOWS, + Response::MODEL_PLATFORM_LINUX, + ], 'description' => 'List of Platforms.', 'default' => [], 'example' => new \stdClass(), diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 517ad4807d..1ae8d5cb7b 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class Webhook extends Model { - /** - * @var bool - */ - protected bool $public = true; - public function __construct() { $this diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index eea53d9ea8..f6eb963967 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1324,8 +1324,8 @@ class UsageTest extends Scope $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); $this->validateDates($response['body']['requests']); // vectordbTotal should reflect only VectorsDB instances, not relational databases. - $this->assertEquals($vectordbTotal, $response['body']['vectordbDatabasesTotal']); - $this->assertEquals($documentsTotal, $response['body']['vectordbDocumentsTotal']); + $this->assertEquals($vectordbTotal, $response['body']['vectorsdbDatabasesTotal']); + $this->assertEquals($documentsTotal, $response['body']['vectorsdbDocumentsTotal']); }); $response = $this->client->call( diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 107dceaa5e..ee1bb31ede 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -4150,4 +4150,178 @@ 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. + */ + 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}", + ]); + } + } } diff --git a/tests/e2e/Services/Project/LabelsBase.php b/tests/e2e/Services/Project/LabelsBase.php new file mode 100644 index 0000000000..2b7074ef46 --- /dev/null +++ b/tests/e2e/Services/Project/LabelsBase.php @@ -0,0 +1,224 @@ +updateLabels(['frontend', 'backend']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['labels']); + $this->assertCount(2, $response['body']['labels']); + $this->assertContains('frontend', $response['body']['labels']); + $this->assertContains('backend', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsReplace(): void + { + $response = $this->updateLabels(['alpha', 'beta']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['labels']); + $this->assertContains('alpha', $response['body']['labels']); + $this->assertContains('beta', $response['body']['labels']); + + // Replace with new labels + $response = $this->updateLabels(['gamma', 'delta', 'epsilon']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(3, $response['body']['labels']); + $this->assertContains('gamma', $response['body']['labels']); + $this->assertContains('delta', $response['body']['labels']); + $this->assertContains('epsilon', $response['body']['labels']); + $this->assertNotContains('alpha', $response['body']['labels']); + $this->assertNotContains('beta', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsEmpty(): void + { + // Set some labels first + $response = $this->updateLabels(['toRemove']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['labels']); + + // Clear all labels + $response = $this->updateLabels([]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['labels']); + $this->assertCount(0, $response['body']['labels']); + } + + public function testUpdateLabelsDeduplicated(): void + { + $response = $this->updateLabels(['duplicate', 'duplicate', 'unique']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['labels']); + $this->assertContains('duplicate', $response['body']['labels']); + $this->assertContains('unique', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsSingleLabel(): void + { + $response = $this->updateLabels(['solo']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['labels']); + $this->assertContains('solo', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsWithoutAuthentication(): void + { + $response = $this->updateLabels(['unauthorized'], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidLabelTooLong(): void + { + $response = $this->updateLabels([str_repeat('a', 37)]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidLabelCharacters(): void + { + $response = $this->updateLabels(['invalid-label!']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsAlphanumericOnly(): void + { + $response = $this->updateLabels(['ABC123', 'lowercase', 'UPPERCASE', '0123456789']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(4, $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsMaxLength(): void + { + $label = str_repeat('a', 36); + $response = $this->updateLabels([$label]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['labels']); + $this->assertContains($label, $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsIdempotent(): void + { + $labels = ['stable', 'production']; + + $first = $this->updateLabels($labels); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->updateLabels($labels); + $this->assertSame(200, $second['headers']['status-code']); + + $this->assertSame($first['body']['labels'], $second['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsDeduplicatedOrder(): void + { + $response = $this->updateLabels(['b', 'a', 'b']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['labels']); + $this->assertSame('b', $response['body']['labels'][0]); + $this->assertSame('a', $response['body']['labels'][1]); + + // Cleanup + $this->updateLabels([]); + } + + public function testUpdateLabelsInvalidHyphen(): void + { + $response = $this->updateLabels(['my-label']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidUnderscore(): void + { + $response = $this->updateLabels(['my_label']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidSpace(): void + { + $response = $this->updateLabels(['my label']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsInvalidEmptyString(): void + { + $response = $this->updateLabels(['']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateLabelsResponseModel(): void + { + $response = $this->updateLabels(['test']); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('labels', $response['body']); + $this->assertIsArray($response['body']['labels']); + $this->assertContains('test', $response['body']['labels']); + + // Cleanup + $this->updateLabels([]); + } + + // Helpers + + /** + * @param array $labels + */ + protected function updateLabels(array $labels, 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(\Tests\E2E\Client::METHOD_PUT, '/project/labels', $headers, [ + 'labels' => $labels, + ]); + } +} diff --git a/tests/e2e/Services/Project/LabelsConsoleClientTest.php b/tests/e2e/Services/Project/LabelsConsoleClientTest.php new file mode 100644 index 0000000000..dd724338d6 --- /dev/null +++ b/tests/e2e/Services/Project/LabelsConsoleClientTest.php @@ -0,0 +1,14 @@ +createWebPlatform( + ID::unique(), + 'My Web App', + 'app.example.com', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Web App', $platform['body']['name']); + $this->assertSame('web', $platform['body']['type']); + $this->assertSame('app.example.com', $platform['body']['hostname']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platform['body']['$id'], $get['body']['$id']); + $this->assertSame('My Web App', $get['body']['name']); + $this->assertSame('web', $get['body']['type']); + $this->assertSame('app.example.com', $get['body']['hostname']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['platforms'])); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateWebPlatformWithoutAuthentication(): void + { + $response = $this->createWebPlatform( + ID::unique(), + 'No Auth Web', + 'noauth.example.com', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateWebPlatformInvalidId(): void + { + $platform = $this->createWebPlatform( + '!invalid-id!', + 'Invalid ID Web', + 'invalid.example.com', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateWebPlatformMissingName(): void + { + $response = $this->createWebPlatform( + ID::unique(), + null, + 'missing.example.com', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWebPlatformEmptyHostname(): void + { + $response = $this->createWebPlatform( + ID::unique(), + 'Empty Hostname', + '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWebPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createWebPlatform( + $platformId, + 'Web Dup 1', + 'dup1.example.com', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createWebPlatform( + $platformId, + 'Web Dup 2', + 'dup2.example.com', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateWebPlatformCustomId(): void + { + $customId = 'my-custom-web-platform'; + + $platform = $this->createWebPlatform( + $customId, + 'Custom ID Web', + 'custom.example.com', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Apple platform tests + // ========================================================================= + + public function testCreateApplePlatform(): void + { + $platform = $this->createApplePlatform( + ID::unique(), + 'My Apple App', + 'com.example.myapp', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Apple App', $platform['body']['name']); + $this->assertSame('apple', $platform['body']['type']); + $this->assertSame('com.example.myapp', $platform['body']['bundleIdentifier']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platform['body']['$id'], $get['body']['$id']); + $this->assertSame('My Apple App', $get['body']['name']); + $this->assertSame('apple', $get['body']['type']); + $this->assertSame('com.example.myapp', $get['body']['bundleIdentifier']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['platforms'])); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateApplePlatformWithoutAuthentication(): void + { + $response = $this->createApplePlatform( + ID::unique(), + 'No Auth Apple', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateApplePlatformInvalidId(): void + { + $platform = $this->createApplePlatform( + '!invalid-id!', + 'Invalid ID Apple', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateApplePlatformMissingName(): void + { + $response = $this->createApplePlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateApplePlatformMissingIdentifier(): void + { + $response = $this->createApplePlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateApplePlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createApplePlatform( + $platformId, + 'Apple Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createApplePlatform( + $platformId, + 'Apple Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateApplePlatformCustomId(): void + { + $customId = 'my-custom-apple-platform'; + + $platform = $this->createApplePlatform( + $customId, + 'Custom ID Apple', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Android platform tests + // ========================================================================= + + public function testCreateAndroidPlatform(): void + { + $platform = $this->createAndroidPlatform( + ID::unique(), + 'My Android App', + 'com.example.android', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Android App', $platform['body']['name']); + $this->assertSame('android', $platform['body']['type']); + $this->assertSame('com.example.android', $platform['body']['applicationId']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('android', $get['body']['type']); + $this->assertSame('com.example.android', $get['body']['applicationId']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateAndroidPlatformWithoutAuthentication(): void + { + $response = $this->createAndroidPlatform( + ID::unique(), + 'No Auth Android', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateAndroidPlatformInvalidId(): void + { + $platform = $this->createAndroidPlatform( + '!invalid-id!', + 'Invalid ID Android', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateAndroidPlatformMissingName(): void + { + $response = $this->createAndroidPlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateAndroidPlatformMissingIdentifier(): void + { + $response = $this->createAndroidPlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateAndroidPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createAndroidPlatform( + $platformId, + 'Android Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createAndroidPlatform( + $platformId, + 'Android Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateAndroidPlatformCustomId(): void + { + $customId = 'my-custom-android-platform'; + + $platform = $this->createAndroidPlatform( + $customId, + 'Custom ID Android', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Windows platform tests + // ========================================================================= + + public function testCreateWindowsPlatform(): void + { + $platform = $this->createWindowsPlatform( + ID::unique(), + 'My Windows App', + 'com.example.windows', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Windows App', $platform['body']['name']); + $this->assertSame('windows', $platform['body']['type']); + $this->assertSame('com.example.windows', $platform['body']['packageIdentifierName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('windows', $get['body']['type']); + $this->assertSame('com.example.windows', $get['body']['packageIdentifierName']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateWindowsPlatformWithoutAuthentication(): void + { + $response = $this->createWindowsPlatform( + ID::unique(), + 'No Auth Windows', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateWindowsPlatformInvalidId(): void + { + $platform = $this->createWindowsPlatform( + '!invalid-id!', + 'Invalid ID Windows', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateWindowsPlatformMissingName(): void + { + $response = $this->createWindowsPlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWindowsPlatformMissingIdentifier(): void + { + $response = $this->createWindowsPlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateWindowsPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createWindowsPlatform( + $platformId, + 'Windows Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createWindowsPlatform( + $platformId, + 'Windows Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateWindowsPlatformCustomId(): void + { + $customId = 'my-custom-windows-platform'; + + $platform = $this->createWindowsPlatform( + $customId, + 'Custom ID Windows', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Create Linux platform tests + // ========================================================================= + + public function testCreateLinuxPlatform(): void + { + $platform = $this->createLinuxPlatform( + ID::unique(), + 'My Linux App', + 'com.example.linux', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertNotEmpty($platform['body']['$id']); + $this->assertSame('My Linux App', $platform['body']['name']); + $this->assertSame('linux', $platform['body']['type']); + $this->assertSame('com.example.linux', $platform['body']['packageName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($platform['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getPlatform($platform['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('linux', $get['body']['type']); + $this->assertSame('com.example.linux', $get['body']['packageName']); + + // Verify via LIST + $list = $this->listPlatforms(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testCreateLinuxPlatformWithoutAuthentication(): void + { + $response = $this->createLinuxPlatform( + ID::unique(), + 'No Auth Linux', + 'com.example.noauth', + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateLinuxPlatformInvalidId(): void + { + $platform = $this->createLinuxPlatform( + '!invalid-id!', + 'Invalid ID Linux', + 'com.example.invalidid', + ); + + $this->assertSame(400, $platform['headers']['status-code']); + } + + public function testCreateLinuxPlatformMissingName(): void + { + $response = $this->createLinuxPlatform( + ID::unique(), + null, + 'com.example.missingname', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateLinuxPlatformMissingIdentifier(): void + { + $response = $this->createLinuxPlatform( + ID::unique(), + 'Missing Identifier', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateLinuxPlatformDuplicateId(): void + { + $platformId = ID::unique(); + + $platform = $this->createLinuxPlatform( + $platformId, + 'Linux Dup 1', + 'com.example.dup1', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + + $duplicate = $this->createLinuxPlatform( + $platformId, + 'Linux Dup 2', + 'com.example.dup2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('platform_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testCreateLinuxPlatformCustomId(): void + { + $customId = 'my-custom-linux-platform'; + + $platform = $this->createLinuxPlatform( + $customId, + 'Custom ID Linux', + 'com.example.customid', + ); + + $this->assertSame(201, $platform['headers']['status-code']); + $this->assertSame($customId, $platform['body']['$id']); + + // Verify via GET + $get = $this->getPlatform($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deletePlatform($customId); + } + + // ========================================================================= + // Update Web platform tests + // ========================================================================= + + public function testUpdateWebPlatform(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Original Web', 'original.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWebPlatform($platformId, 'Updated Web', 'updated.example.com'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Web', $updated['body']['name']); + $this->assertSame('updated.example.com', $updated['body']['hostname']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Web', $get['body']['name']); + $this->assertSame('updated.example.com', $get['body']['hostname']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWebPlatformWithoutAuthentication(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Auth Update Web', 'authupdate.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateWebPlatform($platformId, 'Updated', 'updated.example.com', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWebPlatformNotFound(): void + { + $updated = $this->updateWebPlatform('non-existent-id', 'New Name', 'new.example.com'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateWebPlatformMethodUnsupported(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Android Platform', 'com.example.app'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWebPlatform($platformId, 'Updated Name', 'updated.example.com'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Apple platform tests + // ========================================================================= + + public function testUpdateApplePlatform(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Original Apple', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateApplePlatform($platformId, 'Updated Apple', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Apple', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['bundleIdentifier']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Apple', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['bundleIdentifier']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateApplePlatformWithoutAuthentication(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Auth Update Apple', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateApplePlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateApplePlatformNotFound(): void + { + $updated = $this->updateApplePlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateApplePlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateApplePlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateApplePlatformMissingIdentifier(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Missing Id Apple', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateApplePlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Android platform tests + // ========================================================================= + + public function testUpdateAndroidPlatform(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Original Android', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateAndroidPlatform($platformId, 'Updated Android', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Android', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['applicationId']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Android', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['applicationId']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateAndroidPlatformWithoutAuthentication(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Auth Update Android', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateAndroidPlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateAndroidPlatformNotFound(): void + { + $updated = $this->updateAndroidPlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateAndroidPlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateAndroidPlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateAndroidPlatformMissingIdentifier(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Missing Id Android', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateAndroidPlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Windows platform tests + // ========================================================================= + + public function testUpdateWindowsPlatform(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Original Windows', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWindowsPlatform($platformId, 'Updated Windows', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Windows', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['packageIdentifierName']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Windows', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['packageIdentifierName']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWindowsPlatformWithoutAuthentication(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Auth Update Windows', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateWindowsPlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWindowsPlatformNotFound(): void + { + $updated = $this->updateWindowsPlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateWindowsPlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWindowsPlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateWindowsPlatformMissingIdentifier(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Missing Id Windows', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateWindowsPlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Update Linux platform tests + // ========================================================================= + + public function testUpdateLinuxPlatform(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Original Linux', 'com.example.original'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateLinuxPlatform($platformId, 'Updated Linux', 'com.example.updated'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($platformId, $updated['body']['$id']); + $this->assertSame('Updated Linux', $updated['body']['name']); + $this->assertSame('com.example.updated', $updated['body']['packageName']); + + // Verify update persisted via GET + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Linux', $get['body']['name']); + $this->assertSame('com.example.updated', $get['body']['packageName']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateLinuxPlatformWithoutAuthentication(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Auth Update Linux', 'com.example.authupdate'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->updateLinuxPlatform($platformId, 'Updated', 'com.example.updated', false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateLinuxPlatformNotFound(): void + { + $updated = $this->updateLinuxPlatform('non-existent-id', 'New Name', 'com.example.new'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('platform_not_found', $updated['body']['type']); + } + + public function testUpdateLinuxPlatformMethodUnsupported(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Web Platform', 'web.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateLinuxPlatform($platformId, 'Updated Name', 'com.example.updated'); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('platform_method_unsupported', $updated['body']['type']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testUpdateLinuxPlatformMissingIdentifier(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Missing Id Linux', 'com.example.missingid'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $updated = $this->updateLinuxPlatform($platformId, 'Updated Name', null); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // Get platform tests + // ========================================================================= + + public function testGetWebPlatform(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Get Test Web', 'gettest.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Web', $get['body']['name']); + $this->assertSame('web', $get['body']['type']); + $this->assertSame('gettest.example.com', $get['body']['hostname']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetApplePlatform(): void + { + $platform = $this->createApplePlatform(ID::unique(), 'Get Test Apple', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Apple', $get['body']['name']); + $this->assertSame('apple', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['bundleIdentifier']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetAndroidPlatform(): void + { + $platform = $this->createAndroidPlatform(ID::unique(), 'Get Test Android', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Android', $get['body']['name']); + $this->assertSame('android', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['applicationId']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetWindowsPlatform(): void + { + $platform = $this->createWindowsPlatform(ID::unique(), 'Get Test Windows', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Windows', $get['body']['name']); + $this->assertSame('windows', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['packageIdentifierName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetLinuxPlatform(): void + { + $platform = $this->createLinuxPlatform(ID::unique(), 'Get Test Linux', 'com.example.gettest'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $get = $this->getPlatform($platformId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($platformId, $get['body']['$id']); + $this->assertSame('Get Test Linux', $get['body']['name']); + $this->assertSame('linux', $get['body']['type']); + $this->assertSame('com.example.gettest', $get['body']['packageName']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testGetPlatformNotFound(): void + { + $get = $this->getPlatform('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('platform_not_found', $get['body']['type']); + } + + public function testGetPlatformWithoutAuthentication(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Auth Get Web', 'authget.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->getPlatform($platformId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + // ========================================================================= + // List platforms tests + // ========================================================================= + + public function testListPlatforms(): void + { + // Create one of each platform type + $web = $this->createWebPlatform(ID::unique(), 'List Web', 'listweb.example.com'); + $this->assertSame(201, $web['headers']['status-code']); + + $apple = $this->createApplePlatform(ID::unique(), 'List Apple', 'com.example.listapple'); + $this->assertSame(201, $apple['headers']['status-code']); + + $android = $this->createAndroidPlatform(ID::unique(), 'List Android', 'com.example.listandroid'); + $this->assertSame(201, $android['headers']['status-code']); + + $windows = $this->createWindowsPlatform(ID::unique(), 'List Windows', 'com.example.listwindows'); + $this->assertSame(201, $windows['headers']['status-code']); + + $linux = $this->createLinuxPlatform(ID::unique(), 'List Linux', 'com.example.listlinux'); + $this->assertSame(201, $linux['headers']['status-code']); + + // List all + $list = $this->listPlatforms(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(5, $list['body']['total']); + $this->assertGreaterThanOrEqual(5, \count($list['body']['platforms'])); + $this->assertIsArray($list['body']['platforms']); + + // Verify structure of returned platforms + foreach ($list['body']['platforms'] as $platform) { + $this->assertArrayHasKey('$id', $platform); + $this->assertArrayHasKey('$createdAt', $platform); + $this->assertArrayHasKey('$updatedAt', $platform); + $this->assertArrayHasKey('name', $platform); + $this->assertArrayHasKey('type', $platform); + } + + // Cleanup + $this->deletePlatform($web['body']['$id']); + $this->deletePlatform($apple['body']['$id']); + $this->deletePlatform($android['body']['$id']); + $this->deletePlatform($windows['body']['$id']); + $this->deletePlatform($linux['body']['$id']); + } + + public function testListPlatformsWithLimit(): void + { + $platform1 = $this->createWebPlatform(ID::unique(), 'Limit Web 1', 'limit1.example.com'); + $this->assertSame(201, $platform1['headers']['status-code']); + + $platform2 = $this->createAndroidPlatform(ID::unique(), 'Limit Android 2', 'com.example.limit2'); + $this->assertSame(201, $platform2['headers']['status-code']); + + $list = $this->listPlatforms([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['platforms']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deletePlatform($platform1['body']['$id']); + $this->deletePlatform($platform2['body']['$id']); + } + + public function testListPlatformsWithOffset(): void + { + $platform1 = $this->createWebPlatform(ID::unique(), 'Offset Web 1', 'offset1.example.com'); + $this->assertSame(201, $platform1['headers']['status-code']); + + $platform2 = $this->createAndroidPlatform(ID::unique(), 'Offset Android 2', 'com.example.offset2'); + $this->assertSame(201, $platform2['headers']['status-code']); + + $listAll = $this->listPlatforms(null, true); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['platforms']); + + $listOffset = $this->listPlatforms([ + Query::offset(1)->toString(), + ], true); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['platforms']); + + // Cleanup + $this->deletePlatform($platform1['body']['$id']); + $this->deletePlatform($platform2['body']['$id']); + } + + public function testListPlatformsWithoutTotal(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'No Total Web', 'nototal.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + + $list = $this->listPlatforms(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['platforms'])); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testListPlatformsCursorPagination(): void + { + $platform1 = $this->createWebPlatform(ID::unique(), 'Cursor Web 1', 'cursor1.example.com'); + $this->assertSame(201, $platform1['headers']['status-code']); + + $platform2 = $this->createAndroidPlatform(ID::unique(), 'Cursor Android 2', 'com.example.cursor2'); + $this->assertSame(201, $platform2['headers']['status-code']); + + $page1 = $this->listPlatforms([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['platforms']); + $cursorId = $page1['body']['platforms'][0]['$id']; + + $page2 = $this->listPlatforms([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['platforms']); + $this->assertNotEquals($cursorId, $page2['body']['platforms'][0]['$id']); + + // Cleanup + $this->deletePlatform($platform1['body']['$id']); + $this->deletePlatform($platform2['body']['$id']); + } + + public function testListPlatformsWithoutAuthentication(): void + { + $response = $this->listPlatforms(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListPlatformsInvalidCursor(): void + { + $list = $this->listPlatforms([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + public function testListPlatformsFilterByType(): void + { + $web = $this->createWebPlatform(ID::unique(), 'Filter Web', 'filter.example.com'); + $this->assertSame(201, $web['headers']['status-code']); + + $android = $this->createAndroidPlatform(ID::unique(), 'Filter Android', 'com.example.filter'); + $this->assertSame(201, $android['headers']['status-code']); + + // Filter by web type + $list = $this->listPlatforms([ + Query::equal('type', ['web'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + foreach ($list['body']['platforms'] as $platform) { + $this->assertSame('web', $platform['type']); + } + + // Filter by android type + $list = $this->listPlatforms([ + Query::equal('type', ['android'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + foreach ($list['body']['platforms'] as $platform) { + $this->assertSame('android', $platform['type']); + } + + // Cleanup + $this->deletePlatform($web['body']['$id']); + $this->deletePlatform($android['body']['$id']); + } + + public function testListPlatformsFilterByName(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'UniqueFilterName', 'filtername.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + + $list = $this->listPlatforms([ + Query::equal('name', ['UniqueFilterName'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertSame('UniqueFilterName', $list['body']['platforms'][0]['name']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + public function testListPlatformsFilterByHostname(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Hostname Filter', 'uniquehostname.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + + $list = $this->listPlatforms([ + Query::equal('hostname', ['uniquehostname.example.com'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertSame('uniquehostname.example.com', $list['body']['platforms'][0]['hostname']); + + // Cleanup + $this->deletePlatform($platform['body']['$id']); + } + + // ========================================================================= + // Delete platform tests + // ========================================================================= + + public function testDeletePlatform(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Delete Web', 'delete.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + // Verify it exists + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deletePlatform($platformId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getPlatform($platformId); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('platform_not_found', $get['body']['type']); + } + + public function testDeletePlatformNotFound(): void + { + $delete = $this->deletePlatform('non-existent-id'); + + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('platform_not_found', $delete['body']['type']); + } + + public function testDeletePlatformWithoutAuthentication(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Delete Auth Web', 'deleteauth.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $response = $this->deletePlatform($platformId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getPlatform($platformId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deletePlatform($platformId); + } + + public function testDeletePlatformRemovedFromList(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Delete List Web', 'deletelist.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $listBefore = $this->listPlatforms(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + $delete = $this->deletePlatform($platformId); + $this->assertSame(204, $delete['headers']['status-code']); + + $listAfter = $this->listPlatforms(null, true); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); + + $ids = \array_column($listAfter['body']['platforms'], '$id'); + $this->assertNotContains($platformId, $ids); + } + + public function testDeletePlatformDoubleDelete(): void + { + $platform = $this->createWebPlatform(ID::unique(), 'Double Delete Web', 'doubledelete.example.com'); + $this->assertSame(201, $platform['headers']['status-code']); + $platformId = $platform['body']['$id']; + + $delete = $this->deletePlatform($platformId); + $this->assertSame(204, $delete['headers']['status-code']); + + $delete = $this->deletePlatform($platformId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('platform_not_found', $delete['body']['type']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function createWebPlatform(string $platformId, ?string $name, ?string $hostname, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($hostname !== null) { + $params['hostname'] = $hostname; + } + + $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_POST, '/project/platforms/web', $headers, $params); + } + + protected function createApplePlatform(string $platformId, ?string $name, ?string $bundleIdentifier, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($bundleIdentifier !== null) { + $params['bundleIdentifier'] = $bundleIdentifier; + } + + $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_POST, '/project/platforms/apple', $headers, $params); + } + + protected function createAndroidPlatform(string $platformId, ?string $name, ?string $applicationId, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($applicationId !== null) { + $params['applicationId'] = $applicationId; + } + + $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_POST, '/project/platforms/android', $headers, $params); + } + + protected function createWindowsPlatform(string $platformId, ?string $name, ?string $packageIdentifierName, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageIdentifierName !== null) { + $params['packageIdentifierName'] = $packageIdentifierName; + } + + $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_POST, '/project/platforms/windows', $headers, $params); + } + + protected function createLinuxPlatform(string $platformId, ?string $name, ?string $packageName, bool $authenticated = true): mixed + { + $params = [ + 'platformId' => $platformId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageName !== null) { + $params['packageName'] = $packageName; + } + + $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_POST, '/project/platforms/linux', $headers, $params); + } + + protected function updateWebPlatform(string $platformId, ?string $name = null, ?string $hostname = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($hostname !== null) { + $params['hostname'] = $hostname; + } + + $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/platforms/web/' . $platformId, $headers, $params); + } + + protected function updateApplePlatform(string $platformId, ?string $name = null, ?string $bundleIdentifier = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($bundleIdentifier !== null) { + $params['bundleIdentifier'] = $bundleIdentifier; + } + + $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/platforms/apple/' . $platformId, $headers, $params); + } + + protected function updateAndroidPlatform(string $platformId, ?string $name = null, ?string $applicationId = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($applicationId !== null) { + $params['applicationId'] = $applicationId; + } + + $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/platforms/android/' . $platformId, $headers, $params); + } + + protected function updateWindowsPlatform(string $platformId, ?string $name = null, ?string $packageIdentifierName = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageIdentifierName !== null) { + $params['packageIdentifierName'] = $packageIdentifierName; + } + + $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/platforms/windows/' . $platformId, $headers, $params); + } + + protected function updateLinuxPlatform(string $platformId, ?string $name = null, ?string $packageName = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($packageName !== null) { + $params['packageName'] = $packageName; + } + + $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/platforms/linux/' . $platformId, $headers, $params); + } + + protected function getPlatform(string $platformId, 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_GET, '/project/platforms/' . $platformId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listPlatforms(?array $queries, ?bool $total, 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_GET, '/project/platforms', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deletePlatform(string $platformId, 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_DELETE, '/project/platforms/' . $platformId, $headers); + } +} diff --git a/tests/e2e/Services/Project/PlatformsConsoleClientTest.php b/tests/e2e/Services/Project/PlatformsConsoleClientTest.php new file mode 100644 index 0000000000..9e6b841b00 --- /dev/null +++ b/tests/e2e/Services/Project/PlatformsConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'web', 'name' => 'Web App', @@ -163,6 +164,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-ios', 'name' => 'Flutter App (iOS)', @@ -175,6 +177,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-android', 'name' => 'Flutter App (Android)', @@ -187,6 +190,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-web', 'name' => 'Flutter App (Web)', @@ -199,6 +203,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-ios', 'name' => 'iOS App', @@ -211,6 +216,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-macos', 'name' => 'macOS App', @@ -223,6 +229,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-watchos', 'name' => 'watchOS App', @@ -235,6 +242,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-tvos', 'name' => 'tvOS App', diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 481a34b070..d6fa0d4f5c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3754,6 +3754,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'web', 'name' => 'Web App', @@ -3773,6 +3774,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-ios', 'name' => 'Flutter App (iOS)', @@ -3781,7 +3783,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('flutter-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly flutter-ios, but new version renames $this->assertEquals('Flutter App (iOS)', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3792,6 +3794,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-android', 'name' => 'Flutter App (Android)', @@ -3800,7 +3803,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('flutter-android', $response['body']['type']); + $this->assertEquals('android', $response['body']['type']); // Origianlly flutter-android, but new version renames $this->assertEquals('Flutter App (Android)', $response['body']['name']); $this->assertEquals('com.example.android', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3811,6 +3814,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-web', 'name' => 'Flutter App (Web)', @@ -3819,7 +3823,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('flutter-web', $response['body']['type']); + $this->assertEquals('web', $response['body']['type']); // Origianlly flutter-web, but new version renames $this->assertEquals('Flutter App (Web)', $response['body']['name']); $this->assertEquals('', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3830,6 +3834,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-ios', 'name' => 'iOS App', @@ -3838,7 +3843,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-ios, but new version renames $this->assertEquals('iOS App', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3849,6 +3854,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-macos', 'name' => 'macOS App', @@ -3857,7 +3863,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-macos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-macos, but new version renames $this->assertEquals('macOS App', $response['body']['name']); $this->assertEquals('com.example.macos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3868,6 +3874,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-watchos', 'name' => 'watchOS App', @@ -3876,7 +3883,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-watchos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-watchos, but new version renames $this->assertEquals('watchOS App', $response['body']['name']); $this->assertEquals('com.example.watchos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3887,6 +3894,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-tvos', 'name' => 'tvOS App', @@ -3895,7 +3903,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals('apple-tvos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly apple-tvos, but new version renames $this->assertEquals('tvOS App', $response['body']['name']); $this->assertEquals('com.example.tvos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3909,6 +3917,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'unknown', 'name' => 'Web App', @@ -3927,6 +3936,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -3950,6 +3960,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -3966,12 +3977,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultteriOSId, $response['body']['$id']); - $this->assertEquals('flutter-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('Flutter App (iOS)', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3982,12 +3994,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterAndroidId, $response['body']['$id']); - $this->assertEquals('flutter-android', $response['body']['type']); + $this->assertEquals('android', $response['body']['type']); $this->assertEquals('Flutter App (Android)', $response['body']['name']); $this->assertEquals('com.example.android', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -3998,12 +4011,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterWebId, $response['body']['$id']); - $this->assertEquals('flutter-web', $response['body']['type']); + $this->assertEquals('web', $response['body']['type']); $this->assertEquals('Flutter App (Web)', $response['body']['name']); $this->assertEquals('', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4014,12 +4028,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleIosId, $response['body']['$id']); - $this->assertEquals('apple-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('iOS App', $response['body']['name']); $this->assertEquals('com.example.ios', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4030,12 +4045,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleMacOsId, $response['body']['$id']); - $this->assertEquals('apple-macos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('macOS App', $response['body']['name']); $this->assertEquals('com.example.macos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4046,12 +4062,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleWatchOsId, $response['body']['$id']); - $this->assertEquals('apple-watchos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('watchOS App', $response['body']['name']); $this->assertEquals('com.example.watchos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4062,12 +4079,13 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleTvOsId, $response['body']['$id']); - $this->assertEquals('apple-tvos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); $this->assertEquals('tvOS App', $response['body']['name']); $this->assertEquals('com.example.tvos', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4079,6 +4097,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/error', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4094,6 +4113,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Web App 2', 'hostname' => 'localhost-new', @@ -4113,6 +4133,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (iOS) 2', 'key' => 'com.example.ios2', @@ -4121,7 +4142,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultteriOSId, $response['body']['$id']); - $this->assertEquals('flutter-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Origianlly flutter-ios, but new version renames $this->assertEquals('Flutter App (iOS) 2', $response['body']['name']); $this->assertEquals('com.example.ios2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4132,6 +4153,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (Android) 2', 'key' => 'com.example.android2', @@ -4140,7 +4162,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterAndroidId, $response['body']['$id']); - $this->assertEquals('flutter-android', $response['body']['type']); + $this->assertEquals('android', $response['body']['type']); // Origianlly flutter-android, but new version renames $this->assertEquals('Flutter App (Android) 2', $response['body']['name']); $this->assertEquals('com.example.android2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4151,6 +4173,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (Web) 2', 'hostname' => 'flutter2.appwrite.io', @@ -4159,7 +4182,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformFultterWebId, $response['body']['$id']); - $this->assertEquals('flutter-web', $response['body']['type']); + $this->assertEquals('web', $response['body']['type']); // Originally flutter-web, but new version renames $this->assertEquals('Flutter App (Web) 2', $response['body']['name']); $this->assertEquals('', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4170,6 +4193,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'iOS App 2', 'key' => 'com.example.ios2', @@ -4178,7 +4202,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleIosId, $response['body']['$id']); - $this->assertEquals('apple-ios', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-ios, but new version renames $this->assertEquals('iOS App 2', $response['body']['name']); $this->assertEquals('com.example.ios2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4189,6 +4213,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'macOS App 2', 'key' => 'com.example.macos2', @@ -4197,7 +4222,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleMacOsId, $response['body']['$id']); - $this->assertEquals('apple-macos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-macos, but new version renames $this->assertEquals('macOS App 2', $response['body']['name']); $this->assertEquals('com.example.macos2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4208,6 +4233,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'watchOS App 2', 'key' => 'com.example.watchos2', @@ -4216,7 +4242,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleWatchOsId, $response['body']['$id']); - $this->assertEquals('apple-watchos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-watchos, but new version renames $this->assertEquals('watchOS App 2', $response['body']['name']); $this->assertEquals('com.example.watchos2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4227,6 +4253,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'tvOS App 2', 'key' => 'com.example.tvos2', @@ -4235,7 +4262,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($platformAppleTvOsId, $response['body']['$id']); - $this->assertEquals('apple-tvos', $response['body']['type']); + $this->assertEquals('apple', $response['body']['type']); // Originally apple-tvos, but new version renames $this->assertEquals('tvOS App 2', $response['body']['name']); $this->assertEquals('com.example.tvos2', $response['body']['key']); $this->assertEquals('', $response['body']['store']); @@ -4247,6 +4274,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/platforms/error', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Flutter App (Android) 2', 'key' => 'com.example.android2', @@ -4265,6 +4293,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'web', 'name' => 'Web App', @@ -4277,6 +4306,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-ios', 'name' => 'Flutter App (iOS)', @@ -4289,6 +4319,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-android', 'name' => 'Flutter App (Android)', @@ -4301,6 +4332,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'flutter-web', 'name' => 'Flutter App (Web)', @@ -4313,6 +4345,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-ios', 'name' => 'iOS App', @@ -4325,6 +4358,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-macos', 'name' => 'macOS App', @@ -4337,6 +4371,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-watchos', 'name' => 'watchOS App', @@ -4349,6 +4384,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'type' => 'apple-tvos', 'name' => 'tvOS App', @@ -4360,6 +4396,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4368,6 +4405,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4375,6 +4413,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4383,6 +4422,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultteriOSId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4390,6 +4430,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4398,6 +4439,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterAndroidId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4405,6 +4447,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4413,6 +4456,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformFultterWebId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4420,6 +4464,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4428,6 +4473,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleIosId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4435,6 +4481,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4443,6 +4490,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleMacOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4450,6 +4498,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4458,6 +4507,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleWatchOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4465,6 +4515,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); @@ -4473,6 +4524,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/platforms/' . $platformAppleTvOsId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 682185964c..15ea260ab5 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3039,8 +3039,21 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals(200, $update['headers']['status-code']); - $event = json_decode($client->receive(), true); + // Drain WebSocket messages until the .update event arrives. + // Earlier events (e.g. a late-arriving .create from the row seed above) are skipped. + $updateEvent = "tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update"; + $event = null; + $deadline = \time() + 10; + while (\time() < $deadline) { + $raw = $client->receive(); + $msg = json_decode($raw, true); + if (($msg['type'] ?? '') === 'event' && \in_array($updateEvent, $msg['data']['events'] ?? [])) { + $event = $msg; + break; + } + } + $this->assertNotNull($event, 'Timed out waiting for the row update event'); $this->assertArrayHasKey('type', $event); $this->assertArrayHasKey('data', $event); $this->assertEquals('event', $event['type']); diff --git a/tests/unit/Event/MockPublisher.php b/tests/unit/Event/MockPublisher.php index a7118d3c09..8363ed4e85 100644 --- a/tests/unit/Event/MockPublisher.php +++ b/tests/unit/Event/MockPublisher.php @@ -9,7 +9,7 @@ class MockPublisher implements Publisher { private array $events = []; - public function enqueue(Queue $queue, array $payload): bool + public function enqueue(Queue $queue, array $payload, bool $priority = false): bool { if (!isset($this->events[$queue->name])) { $this->events[$queue->name] = [];