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 0a279b38fe..8786560b10 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2189,7 +2189,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') { @@ -2212,10 +2212,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/projects.php b/app/controllers/api/projects.php index eb9ea59e2f..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') diff --git a/composer.json b/composer.json index 55bf26e3d3..e944e60401 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 6f07c1811e..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": "3c344eeecc8c36b6581f6ddeb736d924", + "content-hash": "4fb974e9843f6104e40396e7cad4a833", "packages": [ { "name": "adhocore/jwt", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "dev-joins8", + "version": "5.3.19", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "53c74cbdc8196d1226b899a6fe03061f3df35fe2" + "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/53c74cbdc8196d1226b899a6fe03061f3df35fe2", - "reference": "53c74cbdc8196d1226b899a6fe03061f3df35fe2", + "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/joins8" + "source": "https://github.com/utopia-php/database/tree/5.3.19" }, - "time": "2026-04-06T14:13:26+00:00" + "time": "2026-03-31T15:52:08+00:00" }, { "name": "utopia-php/detector", @@ -4269,60 +4269,6 @@ }, "time": "2025-12-18T16:25:10+00:00" }, - { - "name": "utopia-php/framework", - "version": "0.34.19", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/http.git", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/995c119f31866cacd42d63b1f922bf86eabb396c", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c", - "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.19" - }, - "time": "2026-04-08T10:23:17+00:00" - }, { "name": "utopia-php/http", "version": "0.34.19", @@ -4580,16 +4526,16 @@ }, { "name": "utopia-php/migration", - "version": "dev-joins", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "c030a2ac04428e91aa48e95c5561f96f54f1bf5e" + "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/c030a2ac04428e91aa48e95c5561f96f54f1bf5e", - "reference": "c030a2ac04428e91aa48e95c5561f96f54f1bf5e", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", + "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", "shasum": "" }, "require": { @@ -4598,7 +4544,7 @@ "ext-openssl": "*", "halaxa/json-machine": "^1.2", "php": ">=8.1", - "utopia-php/database": "dev-joins8 as 5.3.1", + "utopia-php/database": "5.*", "utopia-php/dsn": "0.2.*", "utopia-php/storage": "1.0.*" }, @@ -4629,9 +4575,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/joins" + "source": "https://github.com/utopia-php/migration/tree/1.9.1" }, - "time": "2026-04-06T14:16:47+00:00" + "time": "2026-03-25T07:05:27+00:00" }, { "name": "utopia-php/mongo", @@ -8480,25 +8426,9 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [ - { - "package": "utopia-php/database", - "version": "dev-joins8", - "alias": "5.3.19", - "alias_normalized": "5.3.19.0" - }, - { - "package": "utopia-php/migration", - "version": "dev-joins", - "alias": "1.9.3", - "alias_normalized": "1.9.3.0" - } - ], + "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "utopia-php/database": 20, - "utopia-php/migration": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8519,5 +8449,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } 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/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/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index f94ba0334a..4eba27c68e 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') @@ -141,84 +141,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/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/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/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 index e33e531017..accc6d5b35 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/android') ->desc('Create project Android platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') 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 index cd12f2da74..3ff958e814 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/android/:platformId') ->desc('Update project Android platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') 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 index 4054face8e..0843bf9a0c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/apple') ->desc('Create project Apple platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') 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 index 95d67be26c..0295075f19 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/apple/:platformId') ->desc('Update project Apple platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 907046d27e..4b58766751 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -33,7 +33,7 @@ class Delete extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Delete project platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php index c5f4b8fc81..de086b13a2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php @@ -32,7 +32,7 @@ class Get extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Get project platform') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'platforms.read') ->label('sdk', new Method( namespace: 'project', group: 'platforms', 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 index ae568740b8..472b41cace 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/linux') ->desc('Create project Linux platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') 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 index 92674d2276..9c1f715c33 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/linux/:platformId') ->desc('Update project Linux platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index f16c0af3fa..6794901c47 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -43,7 +43,7 @@ class Create extends Action ->httpAlias('/v1/projects/:projectId/platforms') ->desc('Create project web platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php index 3677466452..1e1f1b5ac1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -35,7 +35,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Update project web platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') 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 index a7e583cadb..58be45d03b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/windows') ->desc('Create project Windows platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') 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 index 43d6c65d44..5cfb6ee7ea 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/windows/:platformId') ->desc('Update project Windows platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php index 14a67418ee..2953adb4c2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php @@ -36,7 +36,7 @@ class XList extends Action ->httpAlias('/v1/projects/:projectId/platforms') ->desc('List project platforms') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'platforms.read') ->label('sdk', new Method( namespace: 'project', group: 'platforms', 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 131cf7245b..2b0ae8feb1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -34,7 +34,7 @@ class Delete extends Action ->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/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 01fe2fcc04..9fd8366097 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,6 +3,11 @@ 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; @@ -43,6 +48,13 @@ class Http extends Service $this->addAction(DeleteVariable::getName(), new DeleteVariable()); $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + // 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()); 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/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index 60ab49255e..357f00cfdc 100644 --- a/src/Appwrite/Utopia/Request/Filters/V21.php +++ b/src/Appwrite/Utopia/Request/Filters/V21.php @@ -71,6 +71,9 @@ class V21 extends Filter case 'webhooks.create': $content = $this->fillWebhookid($content); break; + case 'project.createKey': + $content = $this->fillKeyId($content); + break; case 'project.createVariable': $content = $this->fillVariableId($content); break; @@ -122,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()'; 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/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 ee1bb31ede..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)) { diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 6fe6e43c67..1d39b57daf 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3539,6 +3539,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 @@ +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'], diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index c77a8119c4..60ba21b56b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3216,6 +3216,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'], @@ -3301,6 +3302,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'], @@ -3676,6 +3678,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'], diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 15ea260ab5..9c768f00d1 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -5305,7 +5305,7 @@ class RealtimeCustomClientTest extends Scope $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'], @@ -5350,7 +5350,7 @@ class RealtimeCustomClientTest extends Scope ], $this->getHeaders()), [ 'value' => 5 ]); - + $this->assertEquals(200, $increment['headers']['status-code']); $response = json_decode($client->receive(), true); @@ -5361,7 +5361,7 @@ class RealtimeCustomClientTest extends Scope $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']); @@ -5394,7 +5394,7 @@ class RealtimeCustomClientTest extends Scope $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']);