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/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/config/oAuthProviders.php b/app/config/oAuthProviders.php index e6acd08c54..cda6459519 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -376,6 +376,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Wordpress', ], + 'x' => [ + 'name' => 'X', + 'developers' => 'https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code', + 'icon' => 'icon-twitter', + 'enabled' => true, + 'sandbox' => false, + 'form' => false, + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\X', + ], 'yahoo' => [ 'name' => 'Yahoo', 'developers' => 'https://developer.yahoo.com/oauth2/guide/flows_authcode/', diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index 8d85662652..228a1437f2 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -3,13 +3,6 @@ // List of scopes for organization (teams) API keys return [ - "platforms.read" => [ - "description" => 'Access to read project\'s platforms', - ], - "platforms.write" => [ - "description" => - 'Access to create, update, and delete project\'s platforms', - ], "projects.read" => [ "description" => 'Access to read organization\'s projects', ], @@ -17,13 +10,6 @@ return [ "description" => "Access to create, update, and delete projects in organization", ], - "keys.read" => [ - "description" => 'Access to read project\'s API keys', - ], - "keys.write" => [ - "description" => - "Access to create, update, and delete project\'s API keys", - ], "devKeys.read" => [ "description" => 'Access to read project\'s development keys', ], diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index f5d8461aff..6c7f75c08e 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -188,4 +188,20 @@ return [ // List of publicly visible scopes "description" => "Access to update project\'s information", ], + "keys.read" => [ + "description" => + "Access to read project\'s keys", + ], + "keys.write" => [ + "description" => + "Access to create, update, and delete project\'s keys", + ], + "platforms.read" => [ + "description" => + "Access to read project\'s platforms", + ], + "platforms.write" => [ + "description" => + "Access to create, update, and delete project\'s platforms", + ], ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index cbdf11225a..67588ffd5d 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) @@ -2185,7 +2185,7 @@ Http::get('/v1/account/tokens/oauth2/:provider') } $host = $platform['consoleHostname'] ?? ''; - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $redirectBase = $protocol . '://' . $host; if ($protocol === 'https' && $port !== '443') { @@ -2208,10 +2208,12 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); + $loginURL = $oauth2->getLoginURL(); + $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($oauth2->getLoginURL()); + ->redirect($loginURL); }); Http::post('/v1/account/tokens/magic-url') diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index 95a8afc963..2a0012bd30 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -1180,6 +1180,7 @@ Http::get('/v1/messaging/providers/:providerId/logs') 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], @@ -2585,6 +2586,7 @@ Http::get('/v1/messaging/topics/:topicId/logs') 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], @@ -3000,6 +3002,7 @@ Http::get('/v1/messaging/subscribers/:subscriberId/logs') 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], @@ -3813,6 +3816,7 @@ Http::get('/v1/messaging/messages/:messageId/logs') 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 4eb537d923..dac6ed456a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -5,28 +5,18 @@ use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; use Appwrite\Event\Mail; use Appwrite\Extend\Exception; -use Appwrite\Network\Platform; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; -use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; -use Utopia\Database\Validator\Datetime as DatetimeValidator; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Emails\Validator\Email; use Utopia\Http\Http; @@ -769,288 +759,6 @@ Http::delete('/v1/projects/:projectId') $response->noContent(); }); -// Keys - -Http::post('/v1/projects/:projectId/keys') - ->desc('Create key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'createKey', - description: '/docs/references/projects/create-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - // TODO: When migrating to Platform API, mark keyId required for consistency - ->param('keyId', 'unique()', fn (Database $dbForPlatform) => new CustomId($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true, ['dbForPlatform'])->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { - $keyId = $keyId == 'unique()' ? ID::unique() : $keyId; - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = new Document([ - '$id' => $keyId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $project->getSequence(), - 'resourceId' => $project->getId(), - 'resourceType' => 'projects', - 'name' => $name, - 'scopes' => $scopes, - 'expire' => $expire, - 'sdks' => [], - 'accessedAt' => null, - 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), - ]); - - try { - $key = $dbForPlatform->createDocument('keys', $key); - } catch (Duplicate) { - throw new Exception(Exception::KEY_ALREADY_EXISTS); - } - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($key, Response::MODEL_KEY); - }); - -Http::get('/v1/projects/:projectId/keys') - ->desc('List keys') - ->groups(['api', 'projects']) - ->label('scope', 'keys.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'listKeys', - description: '/docs/references/projects/list-keys.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY_LIST, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('queries', [], new Keys(), '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(', ', Keys::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('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - // Backwards compatibility - if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { - $queries[] = Query::limit(5000); - } - - $queries[] = Query::equal('resourceType', ['projects']); - $queries[] = Query::equal('resourceInternalId', [$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()); - } - - $keyId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('keys', $keyId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - $keys = $dbForPlatform->find('keys', $queries); - - $response->dynamic(new Document([ - 'keys' => $keys, - 'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0, - ]), Response::MODEL_KEY_LIST); - }); - -Http::get('/v1/projects/:projectId/keys/:keyId') - ->desc('Get key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'getKey', - description: '/docs/references/projects/get-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $response->dynamic($key, Response::MODEL_KEY); - }); - -Http::put('/v1/projects/:projectId/keys/:keyId') - ->desc('Update key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'updateKey', - description: '/docs/references/projects/update-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $key - ->setAttribute('name', $name) - ->setAttribute('scopes', $scopes) - ->setAttribute('expire', $expire); - - $dbForPlatform->updateDocument('keys', $key->getId(), $key); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->dynamic($key, Response::MODEL_KEY); - }); - -Http::delete('/v1/projects/:projectId/keys/:keyId') - ->desc('Delete key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'deleteKey', - description: '/docs/references/projects/delete-key.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('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $dbForPlatform->deleteDocument('keys', $key->getId()); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->noContent(); - }); - // JWT Keys Http::post('/v1/projects/:projectId/jwts') @@ -1093,272 +801,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/api/users.php b/app/controllers/api/users.php index 51fc3d03c7..a8875fc442 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1008,6 +1008,8 @@ Http::get('/v1/users/:userId/logs') 'userId' => ID::custom($log['data']['userId']), 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, + 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 8254a22ac0..11ac345fca 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -186,7 +186,7 @@ Http::init() $user = new User([ '$id' => '', 'status' => true, - 'type' => ACTIVITY_TYPE_APP, + 'type' => ACTIVITY_TYPE_KEY_PROJECT, 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), 'password' => '', 'name' => $apiKey->getName(), @@ -256,7 +256,14 @@ Http::init() } } - $queueForAudits->setUser($user); + $userClone = clone $user; + $userClone->setAttribute('type', match ($apiKey->getType()) { + API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT, + API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, + API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, + default => ACTIVITY_TYPE_KEY_PROJECT, + }); + $queueForAudits->setUser($userClone); } // Apply permission @@ -599,7 +606,9 @@ Http::init() if (! $user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. - $userClone->setAttribute('type', ACTIVITY_TYPE_USER); + if (empty($user->getAttribute('type'))) { + $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); + } $queueForAudits->setUser($userClone); } @@ -781,7 +790,8 @@ Http::shutdown() ->inject('eventProcessor') ->inject('bus') ->inject('apiKey') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey) use ($parseLabel) { + ->inject('mode') + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -883,7 +893,9 @@ Http::shutdown() if (! $user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. - $userClone->setAttribute('type', ACTIVITY_TYPE_USER); + if (empty($user->getAttribute('type'))) { + $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); + } $queueForAudits->setUser($userClone); } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) { /** diff --git a/app/init/constants.php b/app/init/constants.php index ab88be5854..3b907572ab 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -156,9 +156,12 @@ const SESSION_PROVIDER_SERVER = 'server'; /** * Activity associated with user or the app. */ -const ACTIVITY_TYPE_APP = 'app'; const ACTIVITY_TYPE_USER = 'user'; +const ACTIVITY_TYPE_ADMIN = 'admin'; const ACTIVITY_TYPE_GUEST = 'guest'; +const ACTIVITY_TYPE_KEY_PROJECT = 'keyProject'; +const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount'; +const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization'; /** * MFA diff --git a/app/init/database/filters.php b/app/init/database/filters.php index c2ba091529..5a65479424 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -1,5 +1,6 @@ getAuthorization()->skip(fn () => $database + $platforms = $database->getAuthorization()->skip(fn () => $database ->find('platforms', [ Query::equal('projectInternalId', [$document->getSequence()]), Query::limit(APP_LIMIT_SUBQUERY), ])); + + foreach ($platforms as $platform) { + $platform->setAttribute('type', Platform::mapDeprecatedType($platform->getAttribute('type'))); + } + + return $platforms; } ); 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/resources/request.php b/app/init/resources/request.php index 2bec412882..156e151501 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1306,11 +1306,12 @@ return function (Container $container): void { $dsn = new DSN('mysql://' . $project->getAttribute('database')); } - $pool = $pools->get($databaseDSN->getHost()); + $databaseHost = $databaseDSN->getHost(); + $pool = $pools->get($databaseHost); $adapter = new DatabasePool($pool); $database = new Database($adapter, $cache); - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); $database ->setDatabase(APP_DATABASE) @@ -1321,10 +1322,31 @@ return function (Container $container): void { ->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)) { + + // 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((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database diff --git a/app/init/worker/message.php b/app/init/worker/message.php index a444ada91d..95477088ce 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -202,7 +202,8 @@ return function (Container $container): void { } $pools = $register->get('pools'); - $pool = $pools->get($databaseDSN->getHost()); + $databaseHost = $databaseDSN->getHost(); + $pool = $pools->get($databaseHost); $adapter = new DatabasePool($pool); $database = new Database($adapter, $cache); @@ -211,12 +212,32 @@ return function (Container $container): void { ->setAuthorization($authorization); $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); - if (\in_array($dsn->getHost(), $sharedTables, true)) { + // 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((int) $projectDocument->getSequence()) + ->setTenant($projectDocument->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database diff --git a/composer.json b/composer.json index d3474361e2..4ad1ae6120 100644 --- a/composer.json +++ b/composer.json @@ -67,7 +67,7 @@ "utopia-php/emails": "0.6.*", "utopia-php/dns": "1.6.*", "utopia-php/dsn": "0.2.1", - "utopia-php/framework": "0.34.*", + "utopia-php/http": "0.34.*", "utopia-php/fetch": "0.5.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", diff --git a/composer.lock b/composer.lock index d71f78b35d..164b3a036f 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": "e9c38bbebc60849e70e3640aaa4422cd", + "content-hash": "4fb974e9843f6104e40396e7cad4a833", "packages": [ { "name": "adhocore/jwt", @@ -2708,16 +2708,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -2785,7 +2785,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -2805,7 +2805,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -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", @@ -4269,72 +4269,18 @@ }, "time": "2025-12-18T16:25:10+00:00" }, - { - "name": "utopia-php/framework", - "version": "0.34.17", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/http.git", - "reference": "d3e4143b8b06d9823d0c29a06dacefa5a1b93677" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/d3e4143b8b06d9823d0c29a06dacefa5a1b93677", - "reference": "d3e4143b8b06d9823d0c29a06dacefa5a1b93677", - "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.17" - }, - "time": "2026-04-06T04:40:23+00:00" - }, { "name": "utopia-php/http", - "version": "0.34.16", + "version": "0.34.19", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f" + "reference": "995c119f31866cacd42d63b1f922bf86eabb396c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/2b4021ba3f9d476264ce9fd6703d6c79de9add7f", - "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f", + "url": "https://api.github.com/repos/utopia-php/http/zipball/995c119f31866cacd42d63b1f922bf86eabb396c", + "reference": "995c119f31866cacd42d63b1f922bf86eabb396c", "shasum": "" }, "require": { @@ -4373,9 +4319,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.16" + "source": "https://github.com/utopia-php/http/tree/0.34.19" }, - "time": "2026-03-20T10:39:07+00:00" + "time": "2026-04-08T10:23:17+00:00" }, { "name": "utopia-php/image", @@ -4696,16 +4642,16 @@ }, { "name": "utopia-php/platform", - "version": "0.12.0", + "version": "0.12.1", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8" + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/068ee46228f0c3972e6b569f2c86b6c80fe583d8", - "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", "shasum": "" }, "require": { @@ -4741,9 +4687,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.12.0" + "source": "https://github.com/utopia-php/platform/tree/0.12.1" }, - "time": "2026-03-31T14:44:23+00:00" + "time": "2026-04-08T04:11:31+00:00" }, { "name": "utopia-php/pools", @@ -5502,16 +5448,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.14.0", + "version": "1.17.7", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e" + "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/291471d04c3f0e7b9fcc46668a6255a4c0f2947e", + "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e", "shasum": "" }, "require": { @@ -5547,22 +5493,22 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.14.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.7" }, - "time": "2026-03-26T12:50:11+00:00" + "time": "2026-04-08T08:51:05+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -5586,7 +5532,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.44", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", @@ -5630,7 +5576,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -5642,7 +5588,7 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "czproject/git-php", @@ -6258,11 +6204,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -6307,7 +6253,7 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6657,16 +6603,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.17", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b", + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b", "shasum": "" }, "require": { @@ -6688,7 +6634,7 @@ "sebastian/cli-parser": "^4.2.0", "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0.4", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -6735,31 +6681,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-04-08T03:04:19+00:00" }, { "name": "sebastian/cli-parser", @@ -6832,16 +6762,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63", + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63", "shasum": "" }, "require": { @@ -6900,7 +6830,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5" }, "funding": [ { @@ -6920,7 +6850,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-08T04:43:00+00:00" }, { "name": "sebastian/complexity", @@ -7744,16 +7674,16 @@ }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -7810,7 +7740,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -7830,7 +7760,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8164,16 +8094,16 @@ }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { @@ -8205,7 +8135,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -8225,20 +8155,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -8295,7 +8225,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -8315,7 +8245,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "textalk/websocket", diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php new file mode 100644 index 0000000000..d12ce25b33 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -0,0 +1,320 @@ +state; + $state[self::PKCE_STATE_KEY] = $this->encryptPKCEVerifier($this->getPKCEVerifier()); + + return 'https://x.com/i/oauth2/authorize?' . \http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => $this->base64UrlEncode(\json_encode($state, JSON_THROW_ON_ERROR)), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $this->tokens = $this->decodeJsonObject($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $this->tokenEndpointHeaders(), + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + ]) + )); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $this->tokens = $this->decodeJsonObject($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $this->tokenEndpointHeaders(), + \http_build_query([ + 'client_id' => $this->appID, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]) + )); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['id'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['confirmed_email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * X returns a confirmed email only when the app has email access enabled + * and the authenticated user has a confirmed email address. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $this->user = $this->decodeJsonObject($this->request( + 'GET', + 'https://api.x.com/2/users/me?user.fields=confirmed_email', + ['Authorization: Bearer ' . $accessToken] + )); + } + + return $this->user; + } + + /** + * @return array|null + */ + public function parseState(string $state): ?array + { + $decoded = $this->base64UrlDecode($state); + if ($decoded === false) { + return null; + } + + $parsed = \json_decode($decoded, true); + + if (!\is_array($parsed)) { + return null; + } + + $pkce = $parsed[self::PKCE_STATE_KEY] ?? null; + + if (\is_array($pkce)) { + $this->pkceVerifier = $this->decryptPKCEVerifier($pkce); + } + + unset($parsed[self::PKCE_STATE_KEY]); + + return $parsed; + } + + /** + * @return list + */ + private function tokenEndpointHeaders(): array + { + return [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + } + + /** + * @return array + */ + private function decodeJsonObject(string $json): array + { + $decoded = \json_decode($json, true); + + return \is_array($decoded) ? $decoded : []; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(64)); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + + private function encryptPKCEVerifier(string $verifier): array + { + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $key = $this->getPKCEStateKey(); + $tag = null; + + $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); + + if ($data === false || $tag === null) { + throw new \Exception('Failed to encrypt PKCE verifier.'); + } + + return [ + 'data' => $this->base64UrlEncode($data), + 'iv' => \bin2hex($iv), + 'tag' => \bin2hex($tag), + ]; + } + + private function decryptPKCEVerifier(array $payload): string + { + $data = $payload['data'] ?? ''; + $iv = $payload['iv'] ?? ''; + $tag = $payload['tag'] ?? ''; + + if ($data === '' || $iv === '' || $tag === '') { + return ''; + } + + $decodedData = $this->base64UrlDecode($data); + $decodedIv = \hex2bin($iv); + $decodedTag = \hex2bin($tag); + + if ($decodedData === false || $decodedIv === false || $decodedTag === false) { + return ''; + } + + return OpenSSL::decrypt( + $decodedData, + OpenSSL::CIPHER_AES_128_GCM, + $this->getPKCEStateKey(), + OPENSSL_RAW_DATA, + $decodedIv, + $decodedTag + ) ?: ''; + } + + private function getPKCEStateKey(): string + { + $key = System::getEnv('_APP_OPENSSL_KEY_V1', ''); + + if ($key === '') { + throw new \Exception('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); + } + + return $key; + } + + private function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $value): string|false + { + $padding = \strlen($value) % 4; + if ($padding > 0) { + $value .= \str_repeat('=', 4 - $padding); + } + + return \base64_decode(\strtr($value, '-_', '+/'), true); + } + +} 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/Network/Platform.php b/src/Appwrite/Network/Platform.php index 1cf5de91d1..23fb087010 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'; @@ -57,6 +47,36 @@ class Platform self::SCHEME_TAURI => 'Web (Tauri)', ]; + /** + * Map deprecated platform types to their new consolidated types. + * + * The 1.9.x refactor consolidated ~15 platform types into 5 new ones. + * Existing platforms in the database may still have old type values. + * + * @param string $type + * @return string The mapped type, or the original if not deprecated. + */ + public static function mapDeprecatedType(string $type): string + { + $mapping = [ + 'flutter-web' => self::TYPE_WEB, + 'unity' => self::TYPE_WEB, + 'flutter-ios' => self::TYPE_APPLE, + 'flutter-macos' => self::TYPE_APPLE, + 'apple-ios' => self::TYPE_APPLE, + 'apple-macos' => self::TYPE_APPLE, + 'apple-watchos' => self::TYPE_APPLE, + 'apple-tvos' => self::TYPE_APPLE, + 'react-native-ios' => self::TYPE_APPLE, + 'flutter-android' => self::TYPE_ANDROID, + 'react-native-android' => self::TYPE_ANDROID, + 'flutter-windows' => self::TYPE_WINDOWS, + 'flutter-linux' => self::TYPE_LINUX, + ]; + + return $mapping[$type] ?? $type; + } + /** * Get user-friendly platform name from a scheme. * @@ -77,25 +97,28 @@ class Platform $key = strtolower($platform['key'] ?? ''); switch ($type) { + case 'flutter-web': + case 'unity': 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 'flutter-android': + case 'react-native-android': 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 'flutter-windows': + case self::TYPE_WINDOWS: + case 'flutter-linux': + case self::TYPE_LINUX: + case 'flutter-ios': + case 'flutter-macos': + case 'apple-ios': + case 'apple-macos': + case 'apple-watchos': + case 'apple-tvos': + case 'react-native-ios': + case self::TYPE_APPLE: if (!empty($key)) { $hostnames[] = $key; } @@ -120,38 +143,38 @@ class Platform $schemes[] = $scheme; } break; + case 'flutter-web': + case 'unity': 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 'flutter-android': + case 'react-native-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 'flutter-ios': + case 'flutter-macos': + case 'apple-ios': + case 'apple-macos': + case 'apple-watchos': + case 'apple-tvos': + case 'react-native-ios': + 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 'flutter-windows': + case self::TYPE_WINDOWS: $schemes[] = self::SCHEME_WINDOWS; break; - case self::TYPE_FLUTTER_LINUX: + case '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/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php index 60449aeab6..7893a70753 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php @@ -7,9 +7,13 @@ use Appwrite\Platform\Action as AppwriteAction; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Operator; +use Utopia\Database\Query; class Action extends AppwriteAction { + public const LIST_CACHE_FIELD_DOCUMENTS = 'documents'; + public const LIST_CACHE_FIELD_TOTAL = 'total'; + private string $context = DATABASE_TYPE_LEGACY; public function getDatabaseType(): string @@ -101,4 +105,67 @@ class Action extends AppwriteAction return $data; } + + /** + * Stable Redis key for a collection's cached list responses. + * + * All variations (schema × roles × queries) for a single collection live as + * fields inside this one Redis hash, so purging every cached entry for a + * collection is a single O(1) DEL regardless of how many variations have + * been cached. + */ + protected function getListCacheKey(Database $dbForProject, string $collectionId): string + { + return \sprintf( + '%s-cache:%s:%s:%s:collection:%s', + $dbForProject->getCacheName(), + $dbForProject->getAdapter()->getHostname(), + $dbForProject->getNamespace(), + $dbForProject->getTenant(), + $collectionId, + ); + } + + /** + * Hash field for a single variation of a cached list response. + * + * Scoped by the collection schema (attributes + indexes), the caller's + * authorization roles, the exact query set, and the field type — so users + * with different permissions never share entries. + * + * @param Document $collection Collection document (for schema hash) + * @param array $roles Caller authorization roles + * @param array $queries Queries for this list call + * @param string $type LIST_CACHE_FIELD_DOCUMENTS or LIST_CACHE_FIELD_TOTAL + */ + protected function getListCacheField(Document $collection, array $roles, array $queries, string $type): string + { + $schemaHash = \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + ); + + $serialized = \array_map( + static fn ($query) => $query instanceof Query ? $query->toArray() : $query, + $queries, + ); + + return \sprintf( + '%s:%s:%s:%s', + $schemaHash, + \md5(\json_encode($roles)), + \md5(\json_encode($serialized)), + $type, + ); + } + + /** + * Purge every cached list response for a collection. + * + * One DEL on the collection's Redis hash, clearing all variations at once. + */ + protected function purgeListCache(Database $dbForProject, string $collectionId): bool + { + return $dbForProject->getCache()->purge($this->getListCacheKey($dbForProject, $collectionId)); + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index 2f541936a8..4afab449c0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -3,34 +3,29 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections; use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Platform\Action as UtopiaAction; -use Utopia\Platform\Scope\HTTP; -abstract class Action extends UtopiaAction +abstract class Action extends DatabasesAction { /** * The current API context (either 'table' or 'collection'). */ private ?string $context = COLLECTIONS; - private ?string $databaseType = LEGACY; - /** * Get the response model used in the SDK and HTTP responses. */ abstract protected function getResponseModel(): string; - public function setHttpPath(string $path): UtopiaAction + public function setHttpPath(string $path): self { if (\str_contains($path, '/tablesdb')) { $this->context = TABLES; - $this->databaseType = TABLESDB; - } elseif (\str_contains($path, '/vectorsdb')) { - $this->databaseType = VECTORSDB; } - return parent::setHttpPath($path); + parent::setHttpPath($path); + return $this; } /** @@ -41,14 +36,6 @@ abstract class Action extends UtopiaAction return $this->context; } - /** - * Get the current API database type. - */ - protected function getDatabaseType(): string - { - return $this->databaseType; - } - /** * Get the key used in event parameters (e.g., 'collectionId' or 'tableId'). */ diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index a02eb51aba..e0464f7e52 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -207,6 +207,8 @@ class Decrement extends Action ->addMetric($this->getDatabasesOperationWriteMetric(), 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); + $response->dynamic($document, $this->getResponseModel()); + $queueForEvents ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collectionId) @@ -216,7 +218,5 @@ class Decrement extends Action ->setContext('database', $database) ->setContext($this->getCollectionsEventsContext(), $collection) ->setPayload($response->getPayload(), sensitive: $relationships); - - $response->dynamic($document, $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index 305d9b7a8d..de090f9882 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -207,6 +207,8 @@ class Increment extends Action ->addMetric($this->getDatabasesOperationWriteMetric(), 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); + $response->dynamic($document, $this->getResponseModel()); + $queueForEvents ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collectionId) @@ -216,7 +218,5 @@ class Increment extends Action ->setContext('database', $database) ->setContext($this->getCollectionsEventsContext(), $collection) ->setPayload($response->getPayload(), sensitive: $relationships); - - $response->dynamic($document, $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php index 4588e3666b..8aaac5fcb4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php @@ -131,6 +131,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index b3046fe22d..c35eebaea2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -72,7 +72,7 @@ class XList extends Action ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -127,84 +127,59 @@ class XList extends Action } try { - $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + // When there are no select queries, relationship loading is skipped on the + // underlying find() to avoid pulling related documents the caller did not ask for. + $find = $hasSelects + ? fn () => $dbForDatabases->find($collectionTableId, $queries) + : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; - } elseif (! empty($selectQueries)) { + } elseif ((int)$ttl > 0) { + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); + $roles = $dbForProject->getAuthorization()->getRoles(); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - if ((int)$ttl > 0) { - $serializedQueries = []; - foreach ($queries as $query) { - $serializedQueries[] = $query instanceof Query ? $query->toArray() : $query; - } - - $hostname = $dbForProject->getAdapter()->getHostname(); - $roles = $dbForProject->getAuthorization()->getRoles(); - $schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', []))); - $cacheKeyBase = \sprintf( - '%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s', - $dbForProject->getCacheName(), - $hostname, - $dbForProject->getNamespace(), - $dbForProject->getTenant(), - $collectionId, - $schemaHash, - \md5(\json_encode($roles)), - \md5(\json_encode($serializedQueries)) - ); - - $documentsCacheKey = $cacheKeyBase . ':documents'; - $totalCacheKey = $cacheKeyBase . ':total'; - - $documentsCacheHit = $totalDocumentsCacheHit = false; - - $cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl); - - if ($cachedDocuments !== null && - $cachedDocuments !== false && - \is_array($cachedDocuments)) { - $documents = \array_map(function ($doc) { - return new Document($doc); - }, $cachedDocuments); - $documentsCacheHit = true; - } else { - $documents = $dbForDatabases->find($collectionTableId, $queries); - - // Convert Document objects to arrays for caching - $documentsArray = \array_map(function ($doc) { - return $doc->getArrayCopy(); - }, $documents); - $dbForProject->getCache()->save($documentsCacheKey, $documentsArray); - } - - if ($includeTotal) { - $cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl); - if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; - $totalDocumentsCacheHit = true; - } else { - $total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($totalCacheKey, $total); - } - } else { - $total = 0; - } - - $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + $documentsCacheHit = false; + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; } else { - // has selects, allow relationship on documents - $documents = $dbForDatabases->find($collectionTableId, $queries); - $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $documents = $find(); + + // Convert Document objects to arrays for caching + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); } + if ($includeTotal) { + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = $cachedTotal; + } else { + $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { - // has no selects, disable relationship loading on documents - /* @type Document[] $documents */ - $documents = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + $documents = $find(); $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } catch (OrderException $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php index 19b1cbbde1..cc082532f9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php @@ -123,6 +123,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'] ?? null, 'time' => $log['time'] ?? null, 'osCode' => $os['osCode'] ?? null, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php index 1142f38aa9..800df6d044 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php @@ -68,6 +68,7 @@ class Update extends Action ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') @@ -76,7 +77,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, bool $purge, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -117,6 +118,10 @@ class Update extends Action ->setParam('databaseId', $databaseId) ->setParam($this->getEventsParamKey(), $collection->getId()); + if ($purge) { + $this->purgeListCache($dbForProject, $collectionId); + } + $this->addRowBytesInfo($collection, $dbForProject); $response->dynamic($collection, $this->getResponseModel()); 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/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php index 4763a1611d..1ed7e6a63f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php @@ -113,6 +113,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php index 052970fec4..3acedc0379 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php @@ -58,6 +58,7 @@ class Update extends CollectionUpdate ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php index 23541db146..81822df208 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php @@ -107,6 +107,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index ca83b10aae..91c62aea05 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -57,7 +57,7 @@ class XList extends DocumentXList ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, table, schema version (columns and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; row writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php index 88b16d57f0..d10380a0e8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php @@ -60,6 +60,7 @@ class Update extends CollectionUpdate ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('rowSecurity', false, new Boolean(true), 'Enables configuring permissions for individual rows. A user needs one of row or table-level permissions to access a row. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is table enabled? When set to \'disabled\', users cannot access the table but Server SDKs with and API key can still read and write to the table. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this table as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php new file mode 100644 index 0000000000..59d2c1db49 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -0,0 +1,119 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('Create project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].create') + ->label('audits.event', 'project.key.create') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'createKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array|null $scopes + */ + public function action( + string $keyId, + string $name, + ?array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $keyId = ($keyId == 'unique()') ? ID::unique() : $keyId; + + $key = new Document([ + '$id' => $keyId, + '$permissions' => [], + 'resourceInternalId' => $project->getSequence(), + 'resourceId' => $project->getId(), + 'resourceType' => 'projects', + 'name' => $name, + 'scopes' => $scopes ?? [], + 'expire' => $expire, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->createDocument('keys', $key)); + } catch (DuplicateException) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php new file mode 100644 index 0000000000..c5da673e22 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Delete project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].delete') + ->label('audits.event', 'project.key.delete') + ->label('audits.resource', 'project.key/{request.keyId}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'deleteKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Event $queueForEvents, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('keys', $key->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php new file mode 100644 index 0000000000..e43c669e4f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Get project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'getKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php new file mode 100644 index 0000000000..8759faacc1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -0,0 +1,111 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Update project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].update') + ->label('audits.event', 'project.key.update') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'updateKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array|null $scopes + */ + public function action( + string $keyId, + string $name, + ?array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + $updates = new Document([ + 'name' => $name, + 'scopes' => $scopes ?? [], + 'expire' => $expire, + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('keys', $key->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php new file mode 100644 index 0000000000..d243e6f2c3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php @@ -0,0 +1,127 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('List project keys') + ->groups(['api', 'project']) + ->label('scope', 'keys.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'listKeys', + description: <<param('queries', [], new Keys(), '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(', ', Keys::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()); + } + + // Backwards compatibility + if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { + $queries[] = Query::limit(5000); + } + + $queries[] = Query::equal('resourceType', ['projects']); + $queries[] = Query::equal('resourceInternalId', [$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()); + } + + $keyId = $cursor->getValue(); + $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('keys', [ + Query::equal('$id', [$keyId]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), + ])); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $keys = $authorization->skip(fn () => $dbForPlatform->find('keys', $queries)); + $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('keys', $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([ + 'keys' => $keys, + 'total' => $total, + ]), Response::MODEL_KEY_LIST); + } +} 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..accc6d5b35 --- /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', 'platforms.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..3ff958e814 --- /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', 'platforms.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..0843bf9a0c --- /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', 'platforms.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..0295075f19 --- /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', 'platforms.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..4b58766751 --- /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', 'platforms.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..4bbbc2fc8c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php @@ -0,0 +1,92 @@ +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', 'platforms.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::mapDeprecatedType($platform->getAttribute('type')); + $platform->setAttribute('type', $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..472b41cace --- /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', 'platforms.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..9c1f715c33 --- /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', 'platforms.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..6794901c47 --- /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', 'platforms.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..1e1f1b5ac1 --- /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', 'platforms.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..58be45d03b --- /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', 'platforms.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..5cfb6ee7ea --- /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', 'platforms.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..3913f08e75 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php @@ -0,0 +1,130 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/platforms') + ->httpAlias('/v1/projects/:projectId/platforms') + ->desc('List project platforms') + ->groups(['api', 'project']) + ->label('scope', 'platforms.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."); + } + + foreach ($platforms as $platform) { + $platform->setAttribute('type', Platform::mapDeprecatedType($platform->getAttribute('type'))); + } + + $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..2b0ae8feb1 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; @@ -35,7 +34,7 @@ class Delete extends Base ->label('scope', 'project.write') ->label('event', 'variables.[variableId].delete') ->label('audits.event', 'project.variable.delete') - ->label('audits.resource', 'project.variable/{response.$id}') + ->label('audits.resource', 'project.variable/{request.variableId}') ->label('sdk', new Method( namespace: 'project', group: 'variables', 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 4970403032..9fd8366097 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,7 +3,25 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; 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; @@ -21,12 +39,35 @@ 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()); - $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + // Keys + $this->addAction(CreateKey::getName(), new CreateKey()); + $this->addAction(ListKeys::getName(), new ListKeys()); + $this->addAction(GetKey::getName(), new GetKey()); + $this->addAction(DeleteKey::getName(), new DeleteKey()); + $this->addAction(UpdateKey::getName(), new UpdateKey()); + + // 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/Teams/Http/Logs/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php index 486807d5f9..acdb6defc7 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php @@ -103,6 +103,7 @@ class XList extends Action 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, + 'userType' => $log['data']['userType'] ?? null, 'ip' => $log['ip'], 'time' => $log['time'], 'osCode' => $os['osCode'], 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/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 ebc4f6731a..2c03ad3108 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -21,7 +21,6 @@ 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; @@ -448,7 +447,7 @@ class Specs extends Action } $arguments = [ - new Http(new FPMServer($specsContainer), 'UTC'), + $specsContainer, $services, $routes, $models, @@ -482,7 +481,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/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 2534899f67..43f5c97ba6 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -379,7 +379,11 @@ class Migrations extends Action 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ] ]); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index dd4d378345..02fac12a7a 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -4,12 +4,12 @@ namespace Appwrite\SDK\Specification; use Appwrite\Utopia\Response\Model; use Utopia\Config\Config; -use Utopia\Http\Http; +use Utopia\DI\Container; use Utopia\Http\Route; abstract class Format { - protected Http $app; + protected Container $container; /** * @var array @@ -80,9 +80,9 @@ abstract class Format protected array $enumBlacklist = []; - public function __construct(Http $app, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) + public function __construct(Container $container, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) { - $this->app = $app; + $this->container = $container; $this->services = $services; $this->routes = $routes; $this->models = $models; @@ -210,6 +210,28 @@ abstract class Format return $this->services; } + /** + * @param list $injections + * @return array + */ + protected function getResources(array $injections): array + { + $resources = []; + + foreach ($injections as $name) { + $resources[$name] = $this->container->get($name); + } + + return $resources; + } + + protected function getValidator(array $param): mixed + { + return \is_callable($param['validator']) + ? ($param['validator'])(...$this->getResources($param['injections'] ?? [])) + : $param['validator']; + } + protected function getDescriptionContents(?string $description): string { if ($description === null || $description === '') { @@ -769,6 +791,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..7da48fc2ca 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, [ @@ -367,7 +379,7 @@ class OpenAPI3 extends Format /** * @var \Utopia\Validator $validator */ - $validator = (\is_callable($param['validator'])) ? call_user_func_array($param['validator'], $this->app->getResources($param['injections'])) : $param['validator']; + $validator = $this->getValidator($param); $node = [ 'name' => $name, @@ -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..d95f99bb70 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, [ @@ -369,9 +381,7 @@ class Swagger2 extends Format } /** @var Validator $validator */ - $validator = (\is_callable($param['validator'])) - ? ($param['validator'])(...$this->app->getResources($param['injections'])) - : $param['validator']; + $validator = $this->getValidator($param); $node = [ 'name' => $name, @@ -801,6 +811,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; + case 'project.createKey': + $content = $this->fillKeyId($content); + break; case 'project.createVariable': $content = $this->fillVariableId($content); break; @@ -65,6 +125,12 @@ class V21 extends Filter return $content; } + protected function fillKeyId(array $content): array + { + $content['keyId'] = $content['keyId'] ?? 'unique()'; + return $content; + } + protected function fillVariableId(array $content): array { $content['variableId'] = $content['variableId'] ?? 'unique()'; @@ -79,4 +145,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/AuthProvider.php b/src/Appwrite/Utopia/Response/Model/AuthProvider.php index 0171a3c152..2b8f962cd0 100644 --- a/src/Appwrite/Utopia/Response/Model/AuthProvider.php +++ b/src/Appwrite/Utopia/Response/Model/AuthProvider.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class AuthProvider extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/DevKey.php b/src/Appwrite/Utopia/Response/Model/DevKey.php index b8da6c0cfc..45434cde3b 100644 --- a/src/Appwrite/Utopia/Response/Model/DevKey.php +++ b/src/Appwrite/Utopia/Response/Model/DevKey.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class DevKey extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/Log.php b/src/Appwrite/Utopia/Response/Model/Log.php index a8c00280d3..1e48a6bc89 100644 --- a/src/Appwrite/Utopia/Response/Model/Log.php +++ b/src/Appwrite/Utopia/Response/Model/Log.php @@ -40,6 +40,12 @@ class Log extends Model 'default' => '', 'example' => 'admin', ]) + ->addRule('userType', [ + 'type' => self::TYPE_STRING, + 'description' => 'User type who triggered the audit log. Possible values: user, admin, guest, keyProject, keyAccount, keyOrganization.', + 'default' => '', + 'example' => 'user', + ]) ->addRule('ip', [ 'type' => self::TYPE_STRING, 'description' => 'IP session in use when the session was created.', 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/Client.php b/tests/e2e/Client.php index 758133c4c0..d170d56fe4 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -219,7 +219,8 @@ class Client curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); curl_setopt($ch, CURLOPT_TIMEOUT, 120); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders, &$cookies) { + curl_setopt($ch, CURLOPT_COOKIEFILE, ''); // enable in-memory RFC 6265 cookie engine + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { $len = strlen($header); $header = explode(':', $header, 2); @@ -227,12 +228,6 @@ class Client return $len; } - if (strtolower(trim($header[0])) == 'set-cookie') { - $parsed = $this->parseCookie((string)trim($header[1])); - $name = array_key_first($parsed); - $cookies[$name] = $parsed[$name]; - } - $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); return $len; @@ -259,6 +254,11 @@ class Client $responseType = $responseHeaders['content-type'] ?? ''; $responseStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); + foreach (curl_getinfo($ch, CURLINFO_COOKIELIST) as $line) { + $parts = explode("\t", $line); + $cookies[$parts[5]] = $parts[6] ?? ''; + } + if ($decode && $method !== self::METHOD_HEAD) { $strpos = strpos($responseType, ';'); $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; @@ -309,21 +309,6 @@ class Client ]; } - /** - * Parse Cookie String - * - * @param string $cookie - * @return array - */ - public function parseCookie(string $cookie): array - { - $cookies = []; - - parse_str(strtr($cookie, ['&' => '%26', '+' => '%2B', ';' => '&']), $cookies); - - return $cookies; - } - /** * Flatten params array to PHP multiple format * diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index b7037267c5..10641019f0 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -164,7 +164,11 @@ trait ProjectCustom 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ], ]); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 107dceaa5e..951ab179b3 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -802,6 +802,16 @@ class AccountCustomClientTest extends Scope $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $accountResponse = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(200, $accountResponse['headers']['status-code']); + $this->assertEquals($email, $accountResponse['body']['email']); + // apiKey is only available in custom client test $apiKey = $this->getProject()['apiKey']; if (!empty($apiKey)) { @@ -4150,4 +4160,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/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 628928914f..2c5e587fc2 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3527,6 +3527,157 @@ trait DatabasesBase $this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']); } + public function testListDocumentsCachedWithoutSelectQuery(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + $docIds = $data['documentIds']; + + // No Query::select(...) at all — ttl alone should enable caching. + $queries = [ + Query::equal('$id', $docIds)->toString(), + Query::orderAsc('releaseYear')->toString(), + ]; + + // 1. First request populates the cache. + $documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 60, + ]); + + $this->assertEquals(200, $documents1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']); + $this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']); + + // 2. Same request hits cache — proves the gate is ttl > 0, not the presence of a select query. + $documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 60, + ]); + + $this->assertEquals(200, $documents2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']); + $this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']); + $this->assertSame( + $documents1['body'][$this->getRecordResource()], + $documents2['body'][$this->getRecordResource()] + ); + } + + public function testListDocumentsCachePurgedByUpdate(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + $docIds = $data['documentIds']; + + // Use different select queries from other cache tests to avoid cache key collision. + $queries = [ + Query::equal('$id', $docIds)->toString(), + Query::select(['title', 'tagline', '$id'])->toString(), + Query::orderAsc('$createdAt')->toString(), + ]; + + // 1. First request populates the cache. + $documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents1['headers']['status-code']); + $this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']); + + // 2. Same request hits cache. + $documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents2['headers']['status-code']); + $this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']); + + // 3. Update the collection/table with purge=true to invalidate all cached list responses. + $update = $this->client->call(Client::METHOD_PUT, $this->getContainerUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'name' => 'Movies', + 'enabled' => true, + $this->getSecurityParam() => true, + 'purge' => true, + ]); + + $this->assertEquals(200, $update['headers']['status-code']); + + // 4. Same request should now miss cache because purge=true cleared the hash. + $documents3 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents3['headers']['status-code']); + $this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']); + + // 5. Re-reading without purge should hit the freshly populated cache. + $documents4 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents4['headers']['status-code']); + $this->assertEquals('hit', $documents4['headers']['x-appwrite-cache']); + + // 6. Update without purge=true must NOT invalidate the cache. + $update2 = $this->client->call(Client::METHOD_PUT, $this->getContainerUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'name' => 'Movies', + 'enabled' => true, + $this->getSecurityParam() => true, + ]); + + $this->assertEquals(200, $update2['headers']['status-code']); + + $documents5 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents5['headers']['status-code']); + $this->assertEquals('hit', $documents5['headers']['x-appwrite-cache']); + } + public function testGetDocument(): void { $data = $this->getDocumentsList(); diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php new file mode 100644 index 0000000000..fda5ef377f --- /dev/null +++ b/tests/e2e/Services/Project/KeysBase.php @@ -0,0 +1,806 @@ +createKey( + ID::unique(), + 'My API Key', + ['users.read', 'users.write'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['$id']); + $this->assertSame('My API Key', $key['body']['name']); + $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); + $this->assertNotEmpty($key['body']['secret']); + $this->assertSame('', $key['body']['expire']); + $this->assertSame('', $key['body']['accessedAt']); + $this->assertSame([], $key['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($key['body']['$id'], $get['body']['$id']); + $this->assertSame('My API Key', $get['body']['name']); + $this->assertSame(['users.read', 'users.write'], $get['body']['scopes']); + + // Verify via LIST + $list = $this->listKeys(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithExpire(): void + { + $expire = '2030-01-01T00:00:00.000+00:00'; + + $key = $this->createKey( + ID::unique(), + 'Expiring Key', + ['users.read'], + $expire, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($expire, $key['body']['expire']); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithNullScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Null Scopes Key', + null, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithoutAuthentication(): void + { + $response = $this->createKey( + ID::unique(), + 'No Auth Key', + ['users.read'], + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidId(): void + { + $key = $this->createKey( + '!invalid-id!', + 'Invalid ID Key', + ['users.read'], + ); + + $this->assertSame(400, $key['headers']['status-code']); + } + + public function testCreateKeyMissingName(): void + { + $response = $this->createKey( + ID::unique(), + null, + ['users.read'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidScope(): void + { + $response = $this->createKey( + ID::unique(), + 'Invalid Scope Key', + ['invalid.scope'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyDuplicateId(): void + { + $keyId = ID::unique(); + + $key = $this->createKey( + $keyId, + 'Key Dup 1', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createKey( + $keyId, + 'Key Dup 2', + ['users.write'], + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('key_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testCreateKeyCustomId(): void + { + $customId = 'my-custom-key-id'; + + $key = $this->createKey( + $customId, + 'Custom ID Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($customId, $key['body']['$id']); + + // Verify via GET + $get = $this->getKey($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deleteKey($customId); + } + + // ========================================================================= + // Update key tests + // ========================================================================= + + public function testUpdateKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Original Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Update name, scopes, and expire + $expire = '2031-06-15T12:00:00.000+00:00'; + $updated = $this->updateKey($keyId, 'Updated Key', ['users.write', 'databases.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($keyId, $updated['body']['$id']); + $this->assertSame('Updated Key', $updated['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $updated['body']['scopes']); + $this->assertSame($expire, $updated['body']['expire']); + + // Verify update persisted via GET + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Key', $get['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $get['body']['scopes']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyName(): void + { + $key = $this->createKey( + ID::unique(), + 'Name Before', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Name After', ['users.read']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('Name After', $updated['body']['name']); + $this->assertSame(['users.read'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Scopes Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Scopes Key', ['databases.read', 'databases.write']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame(['databases.read', 'databases.write'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeySetExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'No Expire Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame('', $key['body']['expire']); + $keyId = $key['body']['$id']; + + $expire = '2032-12-31T23:59:59.000+00:00'; + $updated = $this->updateKey($keyId, 'No Expire Key', ['users.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($expire, $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyRemoveExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'Expire Key', + ['users.read'], + '2030-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Remove expire by setting to null + $updated = $this->updateKey($keyId, 'Expire Key', ['users.read'], null); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('', $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Update Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt update without authentication + $response = $this->updateKey($keyId, 'Updated Name', ['users.read'], null, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyNotFound(): void + { + $updated = $this->updateKey('non-existent-id', 'New Name', ['users.read']); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('key_not_found', $updated['body']['type']); + } + + public function testUpdateKeyInvalidScope(): void + { + $key = $this->createKey( + ID::unique(), + 'Invalid Scope Update', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Invalid Scope Update', ['invalid.scope']); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // Get key tests + // ========================================================================= + + public function testGetKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Get Test Key', + ['users.read', 'databases.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $get = $this->getKey($keyId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($keyId, $get['body']['$id']); + $this->assertSame('Get Test Key', $get['body']['name']); + $this->assertSame(['users.read', 'databases.read'], $get['body']['scopes']); + $this->assertNotEmpty($get['body']['secret']); + $this->assertSame('', $get['body']['expire']); + $this->assertSame('', $get['body']['accessedAt']); + $this->assertSame([], $get['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testGetKeyNotFound(): void + { + $get = $this->getKey('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testGetKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Get Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt GET without authentication + $response = $this->getKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // List keys tests + // ========================================================================= + + public function testListKeys(): void + { + // Create multiple keys + $key1 = $this->createKey( + ID::unique(), + 'List Key Alpha', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'List Key Beta', + ['databases.read'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + $key3 = $this->createKey( + ID::unique(), + 'List Key Gamma', + ['users.write'], + ); + $this->assertSame(201, $key3['headers']['status-code']); + + // List all + $list = $this->listKeys(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($list['body']['keys'])); + $this->assertIsArray($list['body']['keys']); + + // Verify structure of returned keys + foreach ($list['body']['keys'] as $key) { + $this->assertArrayHasKey('$id', $key); + $this->assertArrayHasKey('$createdAt', $key); + $this->assertArrayHasKey('$updatedAt', $key); + $this->assertArrayHasKey('name', $key); + $this->assertArrayHasKey('scopes', $key); + $this->assertArrayHasKey('secret', $key); + $this->assertArrayHasKey('expire', $key); + $this->assertArrayHasKey('accessedAt', $key); + $this->assertArrayHasKey('sdks', $key); + } + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + $this->deleteKey($key3['body']['$id']); + } + + public function testListKeysWithLimit(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Limit Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Limit Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // List with limit 1 + $list = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['keys']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutTotal(): void + { + $key = $this->createKey( + ID::unique(), + 'No Total Key', + ['users.read'], + ); + $this->assertSame(201, $key['headers']['status-code']); + + // List with total=false + $list = $this->listKeys(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testListKeysCursorPagination(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Cursor Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Cursor Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['keys']); + $cursorId = $page1['body']['keys'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listKeys([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['keys']); + $this->assertNotEquals($cursorId, $page2['body']['keys'][0]['$id']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutAuthentication(): void + { + $response = $this->listKeys(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListKeysInvalidCursor(): void + { + $list = $this->listKeys([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + // ========================================================================= + // Delete key tests + // ========================================================================= + + public function testDeleteKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Verify it exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getKey($keyId); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testDeleteKeyNotFound(): void + { + $delete = $this->deleteKey('non-existent-id'); + + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + public function testDeleteKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Auth Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->deleteKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testDeleteKeyRemovedFromList(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete List Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Get list count before delete + $listBefore = $this->listKeys(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listKeys(null, true); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); + + // Verify the deleted key is not in the list + $ids = \array_column($listAfter['body']['keys'], '$id'); + $this->assertNotContains($keyId, $ids); + } + + public function testDeleteKeyDoubleDelete(): void + { + $key = $this->createKey( + ID::unique(), + 'Double Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // First delete succeeds + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteKey($keyId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param array|null $scopes + */ + protected function createKey(string $keyId, ?string $name, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed + { + $params = [ + 'keyId' => $keyId, + 'scopes' => $scopes, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $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/keys', $headers, $params); + } + + /** + * @param array|null $scopes + */ + protected function updateKey(string $keyId, ?string $name = null, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($scopes !== null) { + $params['scopes'] = $scopes; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $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/keys/' . $keyId, $headers, $params); + } + + protected function getKey(string $keyId, 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/keys/' . $keyId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listKeys(?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/keys', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deleteKey(string $keyId, 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/keys/' . $keyId, $headers); + } +} diff --git a/tests/e2e/Services/Project/KeysConsoleClientTest.php b/tests/e2e/Services/Project/KeysConsoleClientTest.php new file mode 100644 index 0000000000..ad6ed28b77 --- /dev/null +++ b/tests/e2e/Services/Project/KeysConsoleClientTest.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 . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test', 'scopes' => ['teams.read', 'teams.write'], @@ -151,6 +152,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' => 'web', 'name' => 'Web App', @@ -163,6 +165,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 +178,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 +191,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 +204,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 +217,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 +230,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 +243,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..e0f94b64cc 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3161,6 +3161,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Custom', 'scopes' => ['teams.read', 'teams.write'], @@ -3246,6 +3247,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test 2', 'scopes' => ['users.read'], @@ -3621,6 +3623,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key For Deletion', 'scopes' => ['teams.read', 'teams.write'], @@ -3754,6 +3757,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 +3777,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 +3786,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 +3797,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 +3806,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 +3817,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 +3826,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 +3837,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 +3846,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 +3857,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 +3866,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 +3877,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 +3886,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 +3897,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 +3906,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 +3920,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 +3939,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 +3963,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 +3980,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 +3997,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 +4014,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 +4031,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 +4048,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 +4065,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 +4082,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 +4100,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 +4116,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 +4136,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 +4145,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 +4156,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 +4165,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 +4176,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 +4185,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 +4196,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 +4205,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 +4216,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 +4225,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 +4236,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 +4245,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 +4256,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 +4265,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 +4277,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 +4296,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 +4309,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 +4322,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 +4335,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 +4348,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 +4361,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 +4374,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 +4387,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 +4399,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 +4408,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 +4416,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 +4425,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 +4433,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 +4442,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 +4450,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 +4459,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 +4467,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 +4476,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 +4484,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 +4493,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 +4501,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 +4510,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 +4518,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 +4527,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 f6200ed209..9c768f00d1 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']); @@ -5248,4 +5261,154 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + + public function testChannelDatabaseAtomicOperations() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Test Database Create + + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Atomic DB', + ]); + $databaseId = $database['body']['$id']; + + //Test Collection Create + + $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Atomic Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $actorsId = $actors['body']['$id']; + + //Test Attribute Create + + $scoreAttr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $actorsId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/score', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + //Test Document Create + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 10 + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $documentId = $document['body']['$id']; + + $client->receive(); + + // Test Document Increment + $increment = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId . '/score/increment', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'value' => 5 + ]); + + $this->assertEquals(200, $increment['headers']['status-code']); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals(15, $response['data']['payload']['score']); + + sleep(1); + + try { + $client->receive(); + $this->fail('Should not receive duplicate event'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Test Document Decrement + $decrement = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId . '/score/decrement', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'value' => 3 + ]); + + $this->assertEquals(200, $decrement['headers']['status-code']); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals(12, $response['data']['payload']['score']); + + sleep(1); + + try { + $client->receive(); + $this->fail('Should not receive duplicate event'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } }