diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 568eea9551..1c03f04638 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -11,7 +11,6 @@ use Appwrite\OpenSSL\OpenSSL; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Buckets; use Appwrite\Utopia\Database\Validator\Queries\Files; -use Appwrite\Utopia\Database\Validator\Queries\FileTokens; use Appwrite\Utopia\Response; use Utopia\App; use Utopia\Config\Config; @@ -24,7 +23,6 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; @@ -43,7 +41,6 @@ use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\HexColor; -use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -1659,464 +1656,6 @@ App::delete('/v1/storage/buckets/:bucketId/files/:fileId') $response->noContent(); }); -/** File Tokens */ -App::post('/v1/storage/buckets/:bucketId/files/:fileId/tokens') - ->desc('Create file token') - ->groups(['api', 'storage']) - ->label('scope', 'files.write') - ->label('audits.event', 'fileToken.create') - ->label('event', 'buckets.[bucketId].files.[fileId].tokens.[tokenId].create') - ->label('audits.resource', 'token/{response.$id}') - ->label('usage.metric', 'fileTokens.{scope}.requests.create') - ->label('usage.params', ['bucketId:{request.bucketId}', 'fileId:{request.fileId}']) - ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') - ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) - ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) - ->label('sdk.namespace', 'storage') - ->label('sdk.method', 'createFileToken') - ->label('sdk.description', '/docs/references/storage/create-file-token.md') - ->label('sdk.response.code', Response::STATUS_CODE_CREATED) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File unique ID.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'Token expiry date', true) - ->param('permissions', [], new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permission strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->inject('response') - ->inject('dbForProject') - ->inject('user') - ->inject('queueForEvents') - ->action(function (string $bucketId, string $fileId, ?string $expire, ?array $permissions, Response $response, Database $dbForProject, Document $user, Event $queueForEvents) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $token = $dbForProject->createDocument('resourceTokens', new Document([ - '$id' => ID::unique(), - 'secret' => Auth::tokenGenerator(128), - 'resourceId' => $bucketId . ':' . $fileId, - 'resourceInternalId' => $bucket->getInternalId() . ':' . $file->getInternalId(), - 'resourceType' => 'file', - 'expire' => $expire, - '$permissions' => $permissions - ])); - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setParam('tokenId', $token->getId()) - ->setContext('bucket', $bucket) - ; - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($token, Response::MODEL_RESOURCE_TOKEN); - }); - -App::get('/v1/storage/buckets/:bucketId/files/:fileId/tokens') - ->desc('List file tokens') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) - ->label('usage.metric', 'fileTokens.{scope}.requests.read') - ->label('usage.params', ['bucketId:{request.bucketId}']) - ->label('sdk.namespace', 'storage') - ->label('sdk.method', 'listFileTokens') - ->label('sdk.description', '/docs/references/storage/list-file-tokens.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN_LIST) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File unique ID.') - ->param('queries', [], new FileTokens(), '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(', ', FileTokens::ALLOWED_ATTRIBUTES), true) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $bucketId, string $fileId, array $queries, Response $response, Database $dbForProject) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $queries = Query::parseQueries($queries); - // Get cursor document if there was a cursor query - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - $tokenId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('resourceTokens', $tokenId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "File token '{$tokenId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $queries = [...$queries, Query::equal('resourceInternalId', [$bucket->getInternalId() . ':' . $file->getInternalId()]), Query::equal('resourceType', ['file'])]; - $filterQueries = Query::groupByType($queries)['filters']; - - $response->dynamic(new Document([ - 'tokens' => $dbForProject->find('resourceTokens', $queries), - 'total' => $dbForProject->count('resourceTokens', $filterQueries, APP_LIMIT_COUNT), - ]), Response::MODEL_RESOURCE_TOKEN_LIST); - }); - -App::get('/v1/storage/buckets/:bucketId/files/:fileId/tokens/:tokenId') - ->desc('Get file token') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) - ->label('usage.metric', 'fileTokens.{scope}.requests.read') - ->label('usage.params', ['bucketId:{request.bucketId}','fileId:{request.fileId}']) - ->label('sdk.namespace', 'storage') - ->label('sdk.method', 'getFileToken') - ->label('sdk.description', '/docs/references/storage/get-file-token.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->param('tokenId', '', new UID(), 'File token ID.') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $bucketId, string $fileId, string $tokenId, Response $response, Database $dbForProject) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $token = $dbForProject->getDocument('resourceTokens', $tokenId); - - if ($token->isEmpty() || $token->getAttribute('resourceInternalId') != $bucket->getInternalId() . ':' . $file->getInternalId()) { - throw new Exception(Exception::STORAGE_FILE_TOKEN_NOT_FOUND); - } - - $response->dynamic($token, Response::MODEL_RESOURCE_TOKEN); - }); - -// Get token as JWT -App::get('/v1/storage/buckets/:bucketId/files/:fileId/tokens/:tokenId/jwt') - ->desc('Get file token jwt') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) - ->label('usage.metric', 'fileTokens.{scope}.requests.read') - ->label('usage.params', ['bucketId:{request.bucketId}','fileId:{request.fileId}']) - ->label('sdk.namespace', 'storage') - ->label('sdk.method', 'getFileTokenJWT') - ->label('sdk.description', '/docs/references/storage/get-file-token-jwt.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_JWT) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->param('tokenId', '', new UID(), 'File token ID.') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $bucketId, string $fileId, string $tokenId, Response $response, Database $dbForProject) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $token = $dbForProject->getDocument('resourceTokens', $tokenId); - - if ($token->isEmpty() || $token->getAttribute('resourceInternalId') != $bucket->getInternalId() . ':' . $file->getInternalId()) { - throw new Exception(Exception::STORAGE_FILE_TOKEN_NOT_FOUND); - } - - // calculate maxAge based on expiry date - $maxAge = PHP_INT_MAX; - $expire = $token->getAttribute('expire'); - if ($expire != null) { - $now = new \DateTime(); - $expiryDate = new \DateTime($expire); - $maxAge = $expiryDate->getTimestamp() - $now->getTimestamp(); - ; - } - - $jwt = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $maxAge, 10); // Instantiate with key, algo, maxAge and leeway. - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(['jwt' => $jwt->encode([ - 'resourceType' => 'file', - 'resourceId' => $token->getAttribute('resourceId'), - 'resourceInternalId' => $token->getAttribute('resourceInternalId'), - 'tokenId' => $token->getId(), - 'secret' => $token->getAttribute('secret') - ])]), Response::MODEL_JWT); - }); - -App::put('/v1/storage/buckets/:bucketId/files/:fileId/tokens/:tokenId') - ->desc('Update file token') - ->groups(['api', 'storage']) - ->label('scope', 'files.write') - ->label('event', 'buckets.[bucketId].files.[fileId].tokens.[tokenId].update') - ->label('audits.event', 'fileTokens.update') - ->label('audits.resource', 'fileTokens/{response.$id}') - ->label('usage.metric', 'filesTokens.{scope}.requests.update') - ->label('usage.params', ['bucketId:{request.bucketId}', 'fileId:{request.tokenId}']) - ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') - ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) - ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) - ->label('sdk.namespace', 'storage') - ->label('sdk.method', 'updateFileToken') - ->label('sdk.description', '/docs/references/storage/update-file.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File unique ID.') - ->param('tokenId', '', new UID(), 'File token unique ID.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'File token expiry date', true) - ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permission string. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->inject('response') - ->inject('dbForProject') - ->inject('user') - ->inject('mode') - ->inject('queueForEvents') - ->action(function (string $bucketId, string $fileId, string $tokenId, ?string $expire, ?array $permissions, Response $response, Database $dbForProject, Document $user, string $mode, Event $queueForEvents) { - - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $token = $dbForProject->getDocument('resourceTokens', $tokenId); - - if ($token->isEmpty() || $token->getAttribute('resourceInternalId') != $bucket->getInternalId() . ':' . $file->getInternalId()) { - throw new Exception(Exception::STORAGE_FILE_TOKEN_NOT_FOUND); - } - - // Map aggregate permissions into the multiple permissions they represent. - $permissions = Permission::aggregate($permissions, [ - Database::PERMISSION_READ, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ]); - - // Users can only manage their own roles, API keys and Admin users can manage any - $roles = Authorization::getRoles(); - if (!Auth::isAppUser($roles) && !Auth::isPrivilegedUser($roles) && !\is_null($permissions)) { - foreach (Database::PERMISSIONS as $type) { - foreach ($permissions as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != $type) { - continue; - } - $role = (new Role( - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); - } - } - } - } - - if (\is_null($permissions)) { - $permissions = $token->getPermissions() ?? []; - } - - $token - ->setAttribute('expire', $expire) - ->setAttribute('$permissions', $permissions); - - $token = $dbForProject->updateDocument('resourceTokens', $tokenId, $token); - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setParam('tokenId', $token->getId()) - ->setContext('bucket', $bucket) - ; - - $response->dynamic($file, Response::MODEL_RESOURCE_TOKEN); - }); - -App::delete('/v1/storage/buckets/:bucketId/files/:fileId/tokens/:tokenId') - ->desc('Delete file token') - ->groups(['api', 'storage']) - ->label('scope', 'files.write') - ->label('event', 'buckets.[bucketId].files.[fileId].tokens.[tokenId].delete') - ->label('audits.event', 'fileToken.delete') - ->label('audits.resource', 'token/{request.tokenId}') - ->label('usage.metric', 'fileTokens.{scope}.requests.delete') - ->label('usage.params', ['bucketId:{request.bucketId}','fileId:{request.fileId}']) - ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') - ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) - ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) - ->label('sdk.namespace', 'storage') - ->label('sdk.method', 'deleteFileToken') - ->label('sdk.description', '/docs/references/storage/delete-file.md') - ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) - ->label('sdk.response.model', Response::MODEL_NONE) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->param('tokenId', '', new UID(), 'File token ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $bucketId, string $fileId, string $tokenId, Response $response, Database $dbForProject, Event $queueForEvents) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $token = $dbForProject->getDocument('resourceTokens', $tokenId); - if ($token->isEmpty() || $token->getAttribute('resourceInternalId') != $bucket->getInternalId() . ':' . $file->getInternalId()) { - throw new Exception(Exception::STORAGE_FILE_TOKEN_NOT_FOUND); - } - - $dbForProject->deleteDocument('resourceTokens', $tokenId); - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setParam('tokenId', $token->getId()) - ->setContext('bucket', $bucket) - ->setPayload($response->output($file, Response::MODEL_RESOURCE_TOKEN)) - ; - - $response->noContent(); - }); - /** Storage usage */ App::get('/v1/storage/usage') ->desc('Get storage usage stats') diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index d25332126c..9de85eec8a 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -302,6 +302,9 @@ class Exception extends \Exception /** Schedules */ public const SCHEDULE_NOT_FOUND = 'schedule_not_found'; + /** Tokens */ + public const TOKEN_NOT_FOUND = 'token_not_found'; + protected string $type = ''; protected array $errors = []; diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 12f37b9193..a075116d8b 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -3,8 +3,8 @@ namespace Appwrite\Platform; use Appwrite\Platform\Modules\Core; -use Utopia\Platform\Platform; use Appwrite\Platform\Modules\Tokens; +use Utopia\Platform\Platform; class Appwrite extends Platform { diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/CreateToken.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/CreateToken.php new file mode 100644 index 0000000000..3e28b23430 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/CreateToken.php @@ -0,0 +1,123 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/tokens') + ->desc('Create token') + ->groups(['api', 'token']) + ->label('scope', 'tokens.write') + ->label('audits.event', 'token.create') + ->label('event', 'tokens.[tokenId].create') + ->label('audits.resource', 'token/{response.$id}') + ->label('usage.metric', 'tokens.{scope}.requests.create') + ->label('usage.params', ['resourceId:{request.resourceId}', 'resourceType:{request.resourceType}']) + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'tokens') + ->label('sdk.method', 'create') + ->label('sdk.description', '/docs/references/tokens/create.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN) + ->param('resourceType', '', new WhiteList(['files']), 'Resource type one of [files].') + ->param('resourceId', '', new UID(), 'Unique resource ID.') + ->param('expire', null, new Nullable(new DatetimeValidator()), 'Token expiry date', true) + ->param('permissions', [], new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permission strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('queueForEvents') + ->callback(fn ($resourceType, $resourceId, $expire, $permissions, $response, $dbForProject, $user, $queueForEvents) => $this->action($resourceType, $resourceId, $expire, $permissions, $response, $dbForProject, $user, $queueForEvents)); + } + + public function action(string $resourceType, string $resourceId, ?string $expire, ?array $permissions, Response $response, Database $dbForProject, Document $user, Event $queueForEvents) + { + + if ($resourceType === 'files') { + $ids = explode(':', $resourceId); + if (count($ids) !== 2) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource id'); + } + $bucketId = $ids[0]; + $fileId = $ids[1]; + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($fileSecurity && !$valid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $token = $dbForProject->createDocument('resourceTokens', new Document([ + '$id' => ID::unique(), + 'secret' => Auth::tokenGenerator(128), + 'resourceId' => $bucketId . ':' . $fileId, + 'resourceInternalId' => $bucket->getInternalId() . ':' . $file->getInternalId(), + 'resourceType' => 'file', + 'expire' => $expire, + '$permissions' => $permissions + ])); + + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setParam('fileId', $file->getId()) + ->setParam('tokenId', $token->getId()) + ->setContext('bucket', $bucket) + ; + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($token, Response::MODEL_RESOURCE_TOKEN); + } else { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type'); + } + } +} diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/DeleteToken.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/DeleteToken.php new file mode 100644 index 0000000000..d8f463cbd8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/DeleteToken.php @@ -0,0 +1,66 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/tokens/:tokenId') + ->desc('Delete token') + ->groups(['api', 'tokens']) + ->label('scope', 'tokens.write') + ->label('event', 'tokens.[tokenId].delete') + ->label('audits.event', 'tokens.delete') + ->label('audits.resource', 'token/{request.tokenId}') + ->label('usage.metric', 'tokens.{scope}.requests.delete') + ->label('usage.params', ['tokenId:{request.tokenId}']) + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'tokens') + ->label('sdk.method', 'delete') + ->label('sdk.description', '/docs/references/tokens/delete.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('tokenId', '', new UID(), 'Token ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback(fn ($tokenId, $response, $dbForProject, $queueForEvents) => $this->action($tokenId, $response, $dbForProject, $queueForEvents)); + } + + public function action(string $tokenId, Response $response, Database $dbForProject, Event $queueForEvents) + { + $token = $dbForProject->getDocument('resourceTokens', $tokenId); + if ($token->isEmpty()) { + throw new Exception(Exception::TOKEN_NOT_FOUND); + } + + $dbForProject->deleteDocument('resourceTokens', $tokenId); + + $queueForEvents + ->setParam('tokenId', $token->getId()) + ->setPayload($response->output($token, Response::MODEL_RESOURCE_TOKEN)) + ; + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/GetToken.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/GetToken.php new file mode 100644 index 0000000000..d1a895fabc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/GetToken.php @@ -0,0 +1,53 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/tokens/:tokenId') + ->desc('Get token') + ->groups(['api', 'tokens']) + ->label('scope', 'tokens.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('usage.metric', 'tokens.{scope}.requests.read') + ->label('usage.params', ['tokenId:{request.tokenId}']) + ->label('sdk.namespace', 'tokens') + ->label('sdk.method', 'get') + ->label('sdk.description', '/docs/references/tokens/get.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN) + ->param('tokenId', '', new UID(), 'Token ID.') + ->inject('response') + ->inject('dbForProject') + ->callback(fn ($tokenId, $response, $dbForProject) => $this->action($tokenId, $response, $dbForProject)); + } + + public function action(string $tokenId, Response $response, Database $dbForProject) + { + $token = $dbForProject->getDocument('resourceTokens', $tokenId); + + if ($token->isEmpty()) { + throw new Exception(Exception::TOKEN_NOT_FOUND); + } + + $response->dynamic($token, Response::MODEL_RESOURCE_TOKEN); + } +} diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/GetTokenJWT.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/GetTokenJWT.php new file mode 100644 index 0000000000..253b0b6fd0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/GetTokenJWT.php @@ -0,0 +1,76 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId/tokens/:tokenId/jwt') + ->desc('Get file token jwt') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('usage.metric', 'fileTokens.{scope}.requests.read') + ->label('usage.params', ['bucketId:{request.bucketId}','fileId:{request.fileId}']) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'getFileTokenJWT') + ->label('sdk.description', '/docs/references/storage/get-file-token-jwt.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_JWT) + ->param('tokenId', '', new UID(), 'File token ID.') + ->inject('response') + ->inject('dbForProject') + ->callback(fn ($tokenId, $response, $dbForProject) => $this->action($tokenId, $response, $dbForProject)); + } + + public function action(string $tokenId, Response $response, Database $dbForProject) + { + $token = $dbForProject->getDocument('resourceTokens', $tokenId); + + if ($token->isEmpty()) { + throw new Exception(Exception::TOKEN_NOT_FOUND); + } + + // calculate maxAge based on expiry date + $maxAge = PHP_INT_MAX; + $expire = $token->getAttribute('expire'); + if ($expire != null) { + $now = new \DateTime(); + $expiryDate = new \DateTime($expire); + $maxAge = $expiryDate->getTimestamp() - $now->getTimestamp(); + ; + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $maxAge, 10); // Instantiate with key, algo, maxAge and leeway. + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document(['jwt' => $jwt->encode([ + 'resourceType' => $token->getAttribute('resourceType'), + 'resourceId' => $token->getAttribute('resourceId'), + 'resourceInternalId' => $token->getAttribute('resourceInternalId'), + 'tokenId' => $token->getId(), + 'secret' => $token->getAttribute('secret') + ])]), Response::MODEL_JWT); + } +} diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/ListTokens.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/ListTokens.php index 84fdfe9c55..5b8948d357 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/ListTokens.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/ListTokens.php @@ -1,6 +1,14 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId/tokens') + ->desc('List tokens') + ->groups(['api', 'tokens']) + ->label('scope', 'tokens.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('usage.metric', 'tokens.requests.read') + ->label('sdk.namespace', 'tokens') + ->label('sdk.method', 'list') + ->label('sdk.description', '/docs/references/storage/list.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN_LIST) + ->param('queries', [], new FileTokens(), '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(', ', FileTokens::ALLOWED_ATTRIBUTES), true) + ->inject('response') + ->inject('dbForProject') + ->callback(fn ($queries, $response, $dbForProject) => $this->action($queries, $response, $dbForProject)); + } + + public function action(array $queries, Response $response, Database $dbForProject) + { + $queries = Query::parseQueries($queries); + // Get cursor document if there was a cursor query + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + $tokenId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('resourceTokens', $tokenId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(ExtendException::GENERAL_CURSOR_NOT_FOUND, "File token '{$tokenId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $response->dynamic(new Document([ + 'tokens' => $dbForProject->find('resourceTokens', $queries), + 'total' => $dbForProject->count('resourceTokens', $filterQueries, APP_LIMIT_COUNT), + ]), Response::MODEL_RESOURCE_TOKEN_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/UpdateToken.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/UpdateToken.php new file mode 100644 index 0000000000..a20fda3e96 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/UpdateToken.php @@ -0,0 +1,114 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/tokens/:tokenId') + ->desc('Update token') + ->groups(['api', 'tokens']) + ->label('scope', 'tokens.write') + ->label('event', 'tokens.[tokenId].update') + ->label('audits.event', 'tokens.update') + ->label('audits.resource', 'tokens/{response.$id}') + ->label('usage.metric', 'tokens.{scope}.requests.update') + ->label('usage.params', ['tokenId:{request.tokenId}']) + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'tokens') + ->label('sdk.method', 'update') + ->label('sdk.description', '/docs/references/tokens/update.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_RESOURCE_TOKEN) + ->param('tokenId', '', new UID(), 'Token unique ID.') + ->param('expire', null, new Nullable(new DatetimeValidator()), 'File token expiry date', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permission string. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('mode') + ->inject('queueForEvents') + ->callback(fn ($tokenId, $expire, $permission, $response, $dbForProject, $queueForEvents) => $this->action($tokenId, $expire, $permission, $response, $dbForProject, $queueForEvents)); + } + + public function action(string $tokenId, ?string $expire, ?array $permissions, Response $response, Database $dbForProject, Event $queueForEvents) + { + $token = $dbForProject->getDocument('resourceTokens', $tokenId); + + if ($token->isEmpty()) { + throw new Exception(Exception::TOKEN_NOT_FOUND); + } + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions, [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]); + + // Users can only manage their own roles, API keys and Admin users can manage any + $roles = Authorization::getRoles(); + if (!Auth::isAppUser($roles) && !Auth::isPrivilegedUser($roles) && !\is_null($permissions)) { + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!Authorization::isRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); + } + } + } + } + + if (\is_null($permissions)) { + $permissions = $token->getPermissions() ?? []; + } + + $token + ->setAttribute('expire', $expire) + ->setAttribute('$permissions', $permissions); + + $token = $dbForProject->updateDocument('resourceTokens', $tokenId, $token); + + $queueForEvents + ->setParam('tokenId', $token->getId()) + ; + + $response->dynamic($token, Response::MODEL_RESOURCE_TOKEN); + } +}