From 26a2180a448251f840bc2a961130b3b7094110f5 Mon Sep 17 00:00:00 2001 From: Darshan Date: Wed, 23 Apr 2025 18:55:36 +0530 Subject: [PATCH 01/21] fix: datetime filter to `file-tokens` expiry. --- app/config/collections/projects.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 19547559e6..704b941505 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2461,7 +2461,7 @@ return [ 'required' => false, 'default' => null, 'array' => false, - 'filters' => [], + 'filters' => ['datetime'], ] ], 'indexes' => [ From 479577f00b668bf000ab194bf0d8c16f8e9cebef Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 24 Apr 2025 09:55:46 +0530 Subject: [PATCH 02/21] add: secret to the response model. --- src/Appwrite/Utopia/Response/Model/ResourceToken.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Appwrite/Utopia/Response/Model/ResourceToken.php b/src/Appwrite/Utopia/Response/Model/ResourceToken.php index 8f44a55b56..074cb2c154 100644 --- a/src/Appwrite/Utopia/Response/Model/ResourceToken.php +++ b/src/Appwrite/Utopia/Response/Model/ResourceToken.php @@ -40,6 +40,12 @@ class ResourceToken extends Model 'default' => '', 'example' => 'file', ]) + ->addRule('secret', [ + 'type' => self::TYPE_STRING, + 'description' => 'Token secret for the resource.', + 'default' => '', + 'example' => 'Bpw_g9c2TGXxfgLshDbSaL8tsCcqgczQ', + ]) ->addRule('expire', [ 'type' => self::TYPE_DATETIME, 'description' => 'Token expiration date in ISO 8601 format.', From 7b88c8035031695d0817357a9071cd384fa0ba00 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 24 Apr 2025 09:55:56 +0530 Subject: [PATCH 03/21] fix: internal query. --- .../Modules/Storage/Http/Tokens/Buckets/Files/XList.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php index aa2637b1d1..562522efad 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php @@ -63,7 +63,7 @@ class XList extends Action $queries = Query::parseQueries($queries); $queries[] = Query::equal('resourceType', ["files"]); - $queries[] = Query::equal('resourceId', [$bucket->getInternalId() . ':' . $file->getInternalId()]); + $queries[] = Query::equal('resourceInternalId', [$bucket->getInternalId() . ':' . $file->getInternalId()]); // 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]); From 9a7c7dee56717ca1cf8313464e3e0463e76b24dd Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 24 Apr 2025 11:08:56 +0530 Subject: [PATCH 04/21] add: last accessed var, remove secret from model. --- app/config/collections/projects.php | 21 +++++++++++++++++-- app/init/constants.php | 1 + app/init/resources.php | 6 ++++++ .../Utopia/Response/Model/ResourceToken.php | 12 +++++------ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 704b941505..d475cdd05c 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2462,7 +2462,18 @@ return [ 'default' => null, 'array' => false, 'filters' => ['datetime'], - ] + ], + [ + '$id' => ID::custom('accessedAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], ], 'indexes' => [ [ @@ -2472,7 +2483,13 @@ return [ 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], - + [ + '$id' => '_key_accessedAt', + 'type' => Database::INDEX_KEY, + 'attributes' => ['accessedAt'], + 'lengths' => [], + 'orders' => [], + ], ], ], ]; diff --git a/app/init/constants.php b/app/init/constants.php index 2b15f9fa0b..3a625980b8 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -29,6 +29,7 @@ const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return const APP_KEY_ACCESS = 24 * 60 * 60; // 24 hours const APP_USER_ACCESS = 24 * 60 * 60; // 24 hours const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours +const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours const APP_CACHE_BUSTER = 4318; diff --git a/app/init/resources.php b/app/init/resources.php index f4ccca3f26..41e866c617 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -930,6 +930,12 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { return new Document([]); } + $accessedAt = $token->getAttribute('accessedAt', 0); + if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), - APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) { + $token->setAttribute('accessedAt', DatabaseDateTime::now()); + Authorization::skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), $token)); + } + return new Document([ 'bucketId' => $ids[0], 'fileId' => $ids[1], diff --git a/src/Appwrite/Utopia/Response/Model/ResourceToken.php b/src/Appwrite/Utopia/Response/Model/ResourceToken.php index 074cb2c154..a49f272a7b 100644 --- a/src/Appwrite/Utopia/Response/Model/ResourceToken.php +++ b/src/Appwrite/Utopia/Response/Model/ResourceToken.php @@ -40,18 +40,18 @@ class ResourceToken extends Model 'default' => '', 'example' => 'file', ]) - ->addRule('secret', [ - 'type' => self::TYPE_STRING, - 'description' => 'Token secret for the resource.', - 'default' => '', - 'example' => 'Bpw_g9c2TGXxfgLshDbSaL8tsCcqgczQ', - ]) ->addRule('expire', [ 'type' => self::TYPE_DATETIME, 'description' => 'Token expiration date in ISO 8601 format.', 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('accessedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Most recent access date in ISO 8601 format. This attribute is only updated again after ' . APP_RESOURCE_TOKEN_ACCESS / 60 / 60 . ' hours.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE + ]) ; } From 948df2b7746d3f2cf39a5db1e1c7846676048d93 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 28 Apr 2025 19:49:32 +0530 Subject: [PATCH 05/21] fix: that permissions issue, pheww! --- app/init/resources.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/init/resources.php b/app/init/resources.php index 41e866c617..d756c24499 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -922,7 +922,7 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { return new Document([]); } - if ($token->getAttribute('resourceType') === 'file') { + if ($token->getAttribute('resourceType') === 'files') { $internalIds = explode(':', $token->getAttribute('resourceInternalId')); $ids = explode(':', $token->getAttribute('resourceId')); From 53e829408f008fc60aafd67cf276a449aac70a0d Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 28 Apr 2025 20:01:42 +0530 Subject: [PATCH 06/21] update: use constants. --- app/init/constants.php | 7 +++++++ app/init/resources.php | 2 +- .../Modules/Storage/Http/Tokens/Buckets/Files/Create.php | 2 +- .../Modules/Storage/Http/Tokens/Buckets/Files/XList.php | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 3a625980b8..143bba29bd 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -258,3 +258,10 @@ const RESOURCE_TYPE_PROVIDERS = 'providers'; const RESOURCE_TYPE_TOPICS = 'topics'; const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers'; const RESOURCE_TYPE_MESSAGES = 'messages'; + +// Resource types for Tokens + +const TOKENS_RESOURCE_TYPE_FILES = 'files'; +const TOKENS_RESOURCE_TYPE_SITES = 'sites'; +const TOKENS_RESOURCE_TYPE_FUNCTIONS = 'functions'; +const TOKENS_RESOURCE_TYPE_DATABASES = 'databases'; diff --git a/app/init/resources.php b/app/init/resources.php index d756c24499..2b72c8da33 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -922,7 +922,7 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { return new Document([]); } - if ($token->getAttribute('resourceType') === 'files') { + if ($token->getAttribute('resourceType') === TOKENS_RESOURCE_TYPE_FILES) { $internalIds = explode(':', $token->getAttribute('resourceInternalId')); $ids = explode(':', $token->getAttribute('resourceId')); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Create.php index 76e161e101..c68573f79f 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Create.php @@ -97,7 +97,7 @@ class Create extends Action 'secret' => Auth::tokenGenerator(128), 'resourceId' => $bucketId . ':' . $fileId, 'resourceInternalId' => $bucket->getInternalId() . ':' . $file->getInternalId(), - 'resourceType' => 'files', + 'resourceType' => TOKENS_RESOURCE_TYPE_FILES, 'expire' => $expire, '$permissions' => $permissions ])); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php index 562522efad..3b98c063e1 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php @@ -62,7 +62,7 @@ class XList extends Action ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $bucketId, $fileId); $queries = Query::parseQueries($queries); - $queries[] = Query::equal('resourceType', ["files"]); + $queries[] = Query::equal('resourceType', [TOKENS_RESOURCE_TYPE_FILES]); $queries[] = Query::equal('resourceInternalId', [$bucket->getInternalId() . ':' . $file->getInternalId()]); // Get cursor document if there was a cursor query $cursor = \array_filter($queries, function ($query) { From 4a8f400f7e5f068c7995650506f5ee079c9a48cb Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 16:23:48 +0530 Subject: [PATCH 07/21] add: token usage on `view` endpoint as well. --- app/controllers/api/storage.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 95927b380a..24952a93ca 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1263,8 +1263,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') ->inject('request') ->inject('dbForProject') ->inject('mode') + ->inject('resourceToken') ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, Response $response, Request $request, Database $dbForProject, string $mode, Device $deviceForFiles) { + ->action(function (string $bucketId, string $fileId, Response $response, Request $request, Database $dbForProject, string $mode, Document $resourceToken, Device $deviceForFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isAPIKey = Auth::isAppUser(Authorization::getRoles()); @@ -1274,10 +1275,11 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } + $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getInternalId(); $fileSecurity = $bucket->getAttribute('fileSecurity', false); $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { + if (!$fileSecurity && !$valid && !$isToken) { throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -1287,6 +1289,10 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); } + if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getInternalId()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } From 6c86faa5d4d38cf1de53b9b0b7bd05f3138740fc Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 18:25:39 +0530 Subject: [PATCH 08/21] add: file tokens tests. --- tests/e2e/Services/Tokens/TokensBase.php | 162 +++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index 11f6897f15..f999d41066 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -2,6 +2,168 @@ namespace Tests\E2E\Services\Tokens; +use CURLFile; +use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; + trait TokensBase { + public function testCreateBucketAndFile(): array + { + $guestHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'name' => 'Test Bucket', + 'bucketId' => ID::unique(), + 'allowedFileExtensions' => ['jpg', 'png', 'jfif'], + 'permissions' => [ + Permission::create(Role::any()), + ], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $this->assertNotEmpty($bucket['body']['$id']); + + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + ]); + + $this->assertEquals(201, $file['headers']['status-code']); + $this->assertNotEmpty($file['body']['$id']); + + $fileId = $file['body']['$id']; + + $token = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders())); + + $this->assertEquals(201, $token['headers']['status-code']); + $this->assertEquals('files', $token['body']['resourceType']); + + return [ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'guestHeaders' => $guestHeaders, + 'tokenId' => $token['body']['$id'], + ]; + } + + /** + * @depends testCreateBucketAndFile + */ + public function testPreviewAccessFailureWithoutToken(array $data): array + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $guestHeaders = $data['guestHeaders']; + + // Fail, anonymous user. + $fileFailedPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', $guestHeaders); + $this->assertEquals(401, $fileFailedPreview['body']['code']); + $this->assertEquals(401, $fileFailedPreview['headers']['status-code']); + $this->assertEquals('user_unauthorized', $fileFailedPreview['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedPreview['body']['message']); + + return $data; + } + + /** + * @depends testCreateBucketAndFile + */ + public function testPreviewAccessFileWithToken(array $data): array + { + $fileId = $data['fileId']; + $tokenId = $data['tokenId']; + $bucketId = $data['bucketId']; + $guestHeaders = $data['guestHeaders']; + + // Generate JWT as an admin user. + $tokenJWT = $this->client->call(Client::METHOD_GET, '/tokens/' . $tokenId . '/jwt/', array_merge($guestHeaders, $this->getHeaders())); + $this->assertEquals(200, $tokenJWT['headers']['status-code']); + $this->assertArrayHasKey('jwt', $tokenJWT['body']); + + $tokenJWT = $tokenJWT['body']['jwt']; + + // Generate a preview + $filePreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview?token=' . $tokenJWT, $guestHeaders); + $this->assertEquals(200, $filePreview['headers']['status-code']); + $this->assertEquals('image/png', $filePreview['headers']['content-type']); + $this->assertNotEmpty($filePreview['body']); + + $image = new \Imagick(); + $image->readImageBlob($filePreview['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); + + $data['jwtToken'] = $tokenJWT; + return $data; + } + + /** + * @depends testPreviewAccessFileWithToken + */ + public function testViewAccessFileWithToken(array $data): void + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $jwtToken = $data['jwtToken']; + $guestHeaders = $data['guestHeaders']; + + $fileView = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/view?token=' . $jwtToken, $guestHeaders); + + $this->assertEquals(200, $fileView['headers']['status-code']); + + $image = new \Imagick(); + $image->readImageBlob($fileView['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); + } + + /** + * @depends testPreviewAccessFileWithToken + */ + public function testDownloadAccessFileWithToken(array $data): void + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $jwtToken = $data['jwtToken']; + $guestHeaders = $data['guestHeaders']; + + /** + * Test should fail because - + * + * 1. There's no token logic on download endpoint + * 2. The user does not have permissions as a guest user + */ + $fileFailedDownload = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download?token=' . $jwtToken, $guestHeaders); + + $this->assertEquals(401, $fileFailedDownload['body']['code']); + $this->assertEquals(401, $fileFailedDownload['headers']['status-code']); + $this->assertEquals('user_unauthorized', $fileFailedDownload['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedDownload['body']['message']); + } } From 8887da9e0b7ee96f51f709f486db105b49d4ca45 Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 18:39:58 +0530 Subject: [PATCH 09/21] add: `tokens` e2e to ci. --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dbf307f962..a7fc1cf0c6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -156,6 +156,7 @@ jobs: Sites, Proxy, Storage, + Tokens, Teams, Users, Webhooks, From 645b04cf32c8118fa346c5e8ef72543fb82990ad Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 19:37:39 +0530 Subject: [PATCH 10/21] fix: tests. --- tests/e2e/Services/Tokens/TokensBase.php | 28 ++++++++----------- .../Tokens/TokensCustomServerTest.php | 24 ++++++++-------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index f999d41066..0d9037b886 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -5,18 +5,11 @@ namespace Tests\E2E\Services\Tokens; use CURLFile; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; trait TokensBase { public function testCreateBucketAndFile(): array { - $guestHeaders = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]; - $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -25,9 +18,6 @@ trait TokensBase 'name' => 'Test Bucket', 'bucketId' => ID::unique(), 'allowedFileExtensions' => ['jpg', 'png', 'jfif'], - 'permissions' => [ - Permission::create(Role::any()), - ], ]); $this->assertEquals(201, $bucket['headers']['status-code']); @@ -35,11 +25,11 @@ trait TokensBase $bucketId = $bucket['body']['$id']; - $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', [ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], - ], $this->getHeaders()), [ + ], [ 'fileId' => ID::unique(), 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), ]); @@ -49,11 +39,11 @@ trait TokensBase $fileId = $file['body']['$id']; - $token = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ - 'content-type' => 'multipart/form-data', + $token = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, [ + 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], - ], $this->getHeaders())); + ]); $this->assertEquals(201, $token['headers']['status-code']); $this->assertEquals('files', $token['body']['resourceType']); @@ -61,8 +51,11 @@ trait TokensBase return [ 'fileId' => $fileId, 'bucketId' => $bucketId, - 'guestHeaders' => $guestHeaders, 'tokenId' => $token['body']['$id'], + 'guestHeaders' => [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], ]; } @@ -94,9 +87,10 @@ trait TokensBase $tokenId = $data['tokenId']; $bucketId = $data['bucketId']; $guestHeaders = $data['guestHeaders']; + $adminHeaders = array_merge($guestHeaders, ['x-appwrite-key' => $this->getProject()['apiKey']]); // Generate JWT as an admin user. - $tokenJWT = $this->client->call(Client::METHOD_GET, '/tokens/' . $tokenId . '/jwt/', array_merge($guestHeaders, $this->getHeaders())); + $tokenJWT = $this->client->call(Client::METHOD_GET, '/tokens/' . $tokenId . '/jwt/', $adminHeaders); $this->assertEquals(200, $tokenJWT['headers']['status-code']); $this->assertArrayHasKey('jwt', $tokenJWT['body']); diff --git a/tests/e2e/Services/Tokens/TokensCustomServerTest.php b/tests/e2e/Services/Tokens/TokensCustomServerTest.php index 47c0600623..f47e0269bf 100644 --- a/tests/e2e/Services/Tokens/TokensCustomServerTest.php +++ b/tests/e2e/Services/Tokens/TokensCustomServerTest.php @@ -11,6 +11,7 @@ use Utopia\Database\DateTime; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Datetime as DatetimeValidator; class TokensCustomServerTest extends Scope { @@ -60,19 +61,19 @@ class TokensCustomServerTest extends Scope $fileId = $file['body']['$id']; - $res = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + $token = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders())); - $this->assertEquals(201, $res['headers']['status-code']); - $this->assertEquals('files', $res['body']['resourceType']); + $this->assertEquals(201, $token['headers']['status-code']); + $this->assertEquals('files', $token['body']['resourceType']); - $data = []; - $data['fileId'] = $fileId; - $data['bucketId'] = $bucketId; - $data['tokenId'] = $res['body']['$id']; - return $data; + return [ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'tokenId' => $token['body']['$id'], + ]; } /** @@ -82,15 +83,16 @@ class TokensCustomServerTest extends Scope { $tokenId = $data['tokenId']; - $expiry = DateTime::now(); - $res = $this->client->call(Client::METHOD_PATCH, '/tokens/' . $tokenId, array_merge([ + $expiry = DateTime::addSeconds(new \DateTime(), 3600); + $token = $this->client->call(Client::METHOD_PATCH, '/tokens/' . $tokenId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'expire' => $expiry, ]); - $this->assertEquals($expiry, $res['body']['expire']); + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($token['body']['expire'])); return $data; } From 5208e695432e14c1f1a9747a7ae1886e7c56e31a Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 19:46:35 +0530 Subject: [PATCH 11/21] add: infinite expiry test. --- tests/e2e/Services/Tokens/TokensCustomServerTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/e2e/Services/Tokens/TokensCustomServerTest.php b/tests/e2e/Services/Tokens/TokensCustomServerTest.php index f47e0269bf..f95b22cea2 100644 --- a/tests/e2e/Services/Tokens/TokensCustomServerTest.php +++ b/tests/e2e/Services/Tokens/TokensCustomServerTest.php @@ -83,6 +83,7 @@ class TokensCustomServerTest extends Scope { $tokenId = $data['tokenId']; + // Finite expiry $expiry = DateTime::addSeconds(new \DateTime(), 3600); $token = $this->client->call(Client::METHOD_PATCH, '/tokens/' . $tokenId, array_merge([ 'content-type' => 'application/json', @@ -93,6 +94,17 @@ class TokensCustomServerTest extends Scope $dateValidator = new DatetimeValidator(); $this->assertTrue($dateValidator->isValid($token['body']['expire'])); + + // Infinite expiry + $token = $this->client->call(Client::METHOD_PATCH, '/tokens/' . $tokenId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'expire' => null, + ]); + + $this->assertEmpty($token['body']['expire']); + return $data; } From f8ddbe5c7ae0a9934140d0666a318372388126de Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 19:51:36 +0530 Subject: [PATCH 12/21] remove: resources' internal ids. --- src/Appwrite/Utopia/Response/Model/ResourceToken.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/ResourceToken.php b/src/Appwrite/Utopia/Response/Model/ResourceToken.php index a49f272a7b..b9915a34d5 100644 --- a/src/Appwrite/Utopia/Response/Model/ResourceToken.php +++ b/src/Appwrite/Utopia/Response/Model/ResourceToken.php @@ -28,17 +28,11 @@ class ResourceToken extends Model 'default' => '', 'example' => '5e5ea5c168bb8:5e5ea5c168bb8', ]) - ->addRule('resourceInternalId', [ - 'type' => self::TYPE_STRING, - 'description' => 'File ID.', - 'default' => '', - 'example' => '1:1', - ]) ->addRule('resourceType', [ 'type' => self::TYPE_STRING, 'description' => 'Resource type.', 'default' => '', - 'example' => 'file', + 'example' => TOKENS_RESOURCE_TYPE_FILES, ]) ->addRule('expire', [ 'type' => self::TYPE_DATETIME, From 9ae22ae3fe19f7054fc5dccf6a53a2a9c5d7deff Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 19:52:14 +0530 Subject: [PATCH 13/21] add: `resourceId` check. --- tests/e2e/Services/Tokens/TokensBase.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index 0d9037b886..2799d66e21 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -47,6 +47,7 @@ trait TokensBase $this->assertEquals(201, $token['headers']['status-code']); $this->assertEquals('files', $token['body']['resourceType']); + $this->assertEquals($bucketId . ':' . $fileId, $token['body']['resourceId']); return [ 'fileId' => $fileId, From eee9adc3255f874c4d91871fe99017a9f943ad1d Mon Sep 17 00:00:00 2001 From: Darshan Date: Tue, 29 Apr 2025 19:52:40 +0530 Subject: [PATCH 14/21] update: use constant. --- tests/e2e/Services/Tokens/TokensBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index 2799d66e21..9c974ac4c6 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -46,8 +46,8 @@ trait TokensBase ]); $this->assertEquals(201, $token['headers']['status-code']); - $this->assertEquals('files', $token['body']['resourceType']); $this->assertEquals($bucketId . ':' . $fileId, $token['body']['resourceId']); + $this->assertEquals(TOKENS_RESOURCE_TYPE_FILES, $token['body']['resourceType']); return [ 'fileId' => $fileId, From 61d583c32eb5ea696c7b13a8f10e6369727a6480 Mon Sep 17 00:00:00 2001 From: Darshan Date: Wed, 30 Apr 2025 09:45:43 +0530 Subject: [PATCH 15/21] update: add `resourceToken` usage to `getFileDownload` as well. --- app/controllers/api/storage.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 5361c17933..604afff0b3 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1133,8 +1133,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') ->inject('response') ->inject('dbForProject') ->inject('mode') + ->inject('resourceToken') ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, Request $request, Response $response, Database $dbForProject, string $mode, Device $deviceForFiles) { + ->action(function (string $bucketId, string $fileId, Request $request, Response $response, Database $dbForProject, string $mode, Document $resourceToken, Device $deviceForFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -1145,10 +1146,11 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } + $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getInternalId(); $fileSecurity = $bucket->getAttribute('fileSecurity', false); $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { + if (!$fileSecurity && !$valid && !$isToken) { throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -1158,6 +1160,10 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); } + if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getInternalId()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } From 1170fd0ee9fba121ebb1684419752e9e82c1c34a Mon Sep 17 00:00:00 2001 From: Darshan Date: Wed, 30 Apr 2025 09:52:27 +0530 Subject: [PATCH 16/21] update: tests for `getFileDownload` with `resourceToken`. --- tests/e2e/Services/Tokens/TokensBase.php | 47 +++++++++++++++--------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index 9c974ac4c6..afa2528ce2 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -63,26 +63,40 @@ trait TokensBase /** * @depends testCreateBucketAndFile */ - public function testPreviewAccessFailureWithoutToken(array $data): array + public function testFailuresWithoutToken(array $data): array { $fileId = $data['fileId']; $bucketId = $data['bucketId']; $guestHeaders = $data['guestHeaders']; - // Fail, anonymous user. + // File preview. Should fail as an anonymous user with no form of any access to the file. $fileFailedPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', $guestHeaders); $this->assertEquals(401, $fileFailedPreview['body']['code']); $this->assertEquals(401, $fileFailedPreview['headers']['status-code']); $this->assertEquals('user_unauthorized', $fileFailedPreview['body']['type']); $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedPreview['body']['message']); + // File view. Should fail as an anonymous user with no form of any access to the file. + $fileFailedView = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', $guestHeaders); + $this->assertEquals(401, $fileFailedView['body']['code']); + $this->assertEquals(401, $fileFailedView['headers']['status-code']); + $this->assertEquals('user_unauthorized', $fileFailedView['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedView['body']['message']); + + // File download. Should fail as an anonymous user with no form of any access to the file. + $fileFailedDownload = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', $guestHeaders); + $this->assertEquals(401, $fileFailedDownload['body']['code']); + $this->assertEquals(401, $fileFailedDownload['headers']['status-code']); + $this->assertEquals('user_unauthorized', $fileFailedDownload['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedDownload['body']['message']); + return $data; } /** * @depends testCreateBucketAndFile */ - public function testPreviewAccessFileWithToken(array $data): array + public function testPreviewFileWithToken(array $data): array { $fileId = $data['fileId']; $tokenId = $data['tokenId']; @@ -116,9 +130,9 @@ trait TokensBase } /** - * @depends testPreviewAccessFileWithToken + * @depends testPreviewFileWithToken */ - public function testViewAccessFileWithToken(array $data): void + public function testViewFileWithToken(array $data): void { $fileId = $data['fileId']; $bucketId = $data['bucketId']; @@ -139,26 +153,25 @@ trait TokensBase } /** - * @depends testPreviewAccessFileWithToken + * @depends testPreviewFileWithToken */ - public function testDownloadAccessFileWithToken(array $data): void + public function testDownloadFileWithToken(array $data): void { $fileId = $data['fileId']; $bucketId = $data['bucketId']; $jwtToken = $data['jwtToken']; $guestHeaders = $data['guestHeaders']; - /** - * Test should fail because - - * - * 1. There's no token logic on download endpoint - * 2. The user does not have permissions as a guest user - */ $fileFailedDownload = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download?token=' . $jwtToken, $guestHeaders); - $this->assertEquals(401, $fileFailedDownload['body']['code']); - $this->assertEquals(401, $fileFailedDownload['headers']['status-code']); - $this->assertEquals('user_unauthorized', $fileFailedDownload['body']['type']); - $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedDownload['body']['message']); + $this->assertEquals(200, $fileFailedDownload['headers']['status-code']); + + $image = new \Imagick(); + $image->readImageBlob($fileFailedDownload['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); } } From 64a24b2211fe86778fdeb6c6a48ee6efe53b1a02 Mon Sep 17 00:00:00 2001 From: Darshan Date: Wed, 30 Apr 2025 10:10:35 +0530 Subject: [PATCH 17/21] update: tests for `getFileDownload` with `resourceToken` and misc changes. --- tests/e2e/Services/Tokens/TokensBase.php | 193 ++++++++++++++++++----- 1 file changed, 152 insertions(+), 41 deletions(-) diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index afa2528ce2..c7ae1d0598 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -10,40 +10,54 @@ trait TokensBase { public function testCreateBucketAndFile(): array { - $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], [ - 'name' => 'Test Bucket', - 'bucketId' => ID::unique(), - 'allowedFileExtensions' => ['jpg', 'png', 'jfif'], - ]); + $bucket = $this->client->call( + Client::METHOD_POST, + '/storage/buckets', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'name' => 'Test Bucket', + 'bucketId' => ID::unique(), + 'allowedFileExtensions' => ['jpg', 'png', 'jfif'], + ] + ); $this->assertEquals(201, $bucket['headers']['status-code']); $this->assertNotEmpty($bucket['body']['$id']); $bucketId = $bucket['body']['$id']; - $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', [ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], [ - 'fileId' => ID::unique(), - 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), - ]); + $file = $this->client->call( + Client::METHOD_POST, + '/storage/buckets/' . $bucketId . '/files', + [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + ] + ); $this->assertEquals(201, $file['headers']['status-code']); $this->assertNotEmpty($file['body']['$id']); $fileId = $file['body']['$id']; - $token = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]); + $token = $this->client->call( + Client::METHOD_POST, + '/tokens/buckets/' . $bucketId . '/files/' . $fileId, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ] + ); $this->assertEquals(201, $token['headers']['status-code']); $this->assertEquals($bucketId . ':' . $fileId, $token['body']['resourceId']); @@ -70,25 +84,56 @@ trait TokensBase $guestHeaders = $data['guestHeaders']; // File preview. Should fail as an anonymous user with no form of any access to the file. - $fileFailedPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', $guestHeaders); - $this->assertEquals(401, $fileFailedPreview['body']['code']); - $this->assertEquals(401, $fileFailedPreview['headers']['status-code']); - $this->assertEquals('user_unauthorized', $fileFailedPreview['body']['type']); - $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedPreview['body']['message']); + $failedPreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $guestHeaders + ); + $this->assertEquals(401, $failedPreview['body']['code']); + $this->assertEquals(401, $failedPreview['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedPreview['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedPreview['body']['message']); + + // Extended file preview. Should fail as an anonymous user with no form of any access to the file. + $failedCustomPreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $guestHeaders, + [ + 'width' => 300, + 'height' => 100, + 'borderRadius' => '50', + 'opacity' => '0.5', + 'output' => 'png', + 'rotation' => '45' + ] + ); + $this->assertEquals(401, $failedCustomPreview['body']['code']); + $this->assertEquals(401, $failedCustomPreview['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedCustomPreview['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedCustomPreview['body']['message']); // File view. Should fail as an anonymous user with no form of any access to the file. - $fileFailedView = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', $guestHeaders); - $this->assertEquals(401, $fileFailedView['body']['code']); - $this->assertEquals(401, $fileFailedView['headers']['status-code']); - $this->assertEquals('user_unauthorized', $fileFailedView['body']['type']); - $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedView['body']['message']); + $failedView = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/view', + $guestHeaders + ); + $this->assertEquals(401, $failedView['body']['code']); + $this->assertEquals(401, $failedView['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedView['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedView['body']['message']); // File download. Should fail as an anonymous user with no form of any access to the file. - $fileFailedDownload = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', $guestHeaders); - $this->assertEquals(401, $fileFailedDownload['body']['code']); - $this->assertEquals(401, $fileFailedDownload['headers']['status-code']); - $this->assertEquals('user_unauthorized', $fileFailedDownload['body']['type']); - $this->assertEquals('The current user is not authorized to perform the requested action.', $fileFailedDownload['body']['message']); + $failedDownload = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', + $guestHeaders + ); + $this->assertEquals(401, $failedDownload['body']['code']); + $this->assertEquals(401, $failedDownload['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedDownload['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedDownload['body']['message']); return $data; } @@ -105,14 +150,25 @@ trait TokensBase $adminHeaders = array_merge($guestHeaders, ['x-appwrite-key' => $this->getProject()['apiKey']]); // Generate JWT as an admin user. - $tokenJWT = $this->client->call(Client::METHOD_GET, '/tokens/' . $tokenId . '/jwt/', $adminHeaders); + $tokenJWT = $this->client->call( + Client::METHOD_GET, + '/tokens/' . $tokenId . '/jwt/', + $adminHeaders + ); $this->assertEquals(200, $tokenJWT['headers']['status-code']); $this->assertArrayHasKey('jwt', $tokenJWT['body']); $tokenJWT = $tokenJWT['body']['jwt']; // Generate a preview - $filePreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview?token=' . $tokenJWT, $guestHeaders); + $filePreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $guestHeaders, + [ + 'token' => $tokenJWT + ] + ); $this->assertEquals(200, $filePreview['headers']['status-code']); $this->assertEquals('image/png', $filePreview['headers']['content-type']); $this->assertNotEmpty($filePreview['body']); @@ -129,6 +185,47 @@ trait TokensBase return $data; } + /** + * @depends testPreviewFileWithToken + */ + public function testCustomPreviewFileWithToken(array $data): array + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $jwtToken = $data['jwtToken']; + $guestHeaders = $data['guestHeaders']; + + // Generate an extended preview + $customFilePreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview/', + $guestHeaders, + [ + 'width' => 300, + 'height' => 100, + 'borderRadius' => '50', + 'opacity' => '0.5', + 'output' => 'png', + 'rotation' => '45', + 'token' => $jwtToken + ] + ); + + $this->assertEquals(200, $customFilePreview['headers']['status-code']); + $this->assertEquals('image/png', $customFilePreview['headers']['content-type']); + $this->assertNotEmpty($customFilePreview['body']); + + $image = new \Imagick(); + $image->readImageBlob($customFilePreview['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo-after.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); + + return $data; + } + /** * @depends testPreviewFileWithToken */ @@ -139,7 +236,14 @@ trait TokensBase $jwtToken = $data['jwtToken']; $guestHeaders = $data['guestHeaders']; - $fileView = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/view?token=' . $jwtToken, $guestHeaders); + $fileView = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/view', + $guestHeaders, + [ + 'token' => $jwtToken + ] + ); $this->assertEquals(200, $fileView['headers']['status-code']); @@ -162,7 +266,14 @@ trait TokensBase $jwtToken = $data['jwtToken']; $guestHeaders = $data['guestHeaders']; - $fileFailedDownload = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download?token=' . $jwtToken, $guestHeaders); + $fileFailedDownload = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', + $guestHeaders, + [ + 'token' => $jwtToken + ] + ); $this->assertEquals(200, $fileFailedDownload['headers']['status-code']); From 093feb58ca64c356a790f527a9bb9aed46d9baf0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 30 Apr 2025 10:00:58 +0000 Subject: [PATCH 18/21] fix: tokens module --- src/Appwrite/Platform/Appwrite.php | 1 - .../Http/Tokens/Buckets/Files/Action.php | 2 +- .../Http/Tokens/Buckets/Files/Create.php | 2 +- .../Http/Tokens/Buckets/Files/XList.php | 2 +- .../{Storage => Tokens}/Http/Tokens/Delete.php | 2 +- .../{Storage => Tokens}/Http/Tokens/Get.php | 2 +- .../{Storage => Tokens}/Http/Tokens/JWT/Get.php | 2 +- .../{Storage => Tokens}/Http/Tokens/Update.php | 2 +- .../Modules/{Storage => Tokens}/Module.php | 4 ++-- .../Modules/{Storage => Tokens}/Services/Http.php | 14 +++++++------- 10 files changed, 16 insertions(+), 17 deletions(-) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Http/Tokens/Buckets/Files/Action.php (95%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Http/Tokens/Buckets/Files/Create.php (98%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Http/Tokens/Buckets/Files/XList.php (98%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Http/Tokens/Delete.php (97%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Http/Tokens/Get.php (97%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Http/Tokens/JWT/Get.php (98%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Http/Tokens/Update.php (98%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Module.php (62%) rename src/Appwrite/Platform/Modules/{Storage => Tokens}/Services/Http.php (53%) diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index ae3d4d6646..d5e3f74f45 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -8,7 +8,6 @@ use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; -use Appwrite\Platform\Modules\Storage; use Utopia\Platform\Platform; class Appwrite extends Platform diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Action.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php similarity index 95% rename from src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Action.php rename to src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php index aec665f406..565ab7bab7 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Action.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php @@ -1,6 +1,6 @@ Date: Wed, 30 Apr 2025 10:02:25 +0000 Subject: [PATCH 19/21] fix: appwrite.php file --- src/Appwrite/Platform/Appwrite.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index d5e3f74f45..5752432257 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -8,6 +8,7 @@ use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; +use Appwrite\Platform\Modules\Tokens; use Utopia\Platform\Platform; class Appwrite extends Platform @@ -20,6 +21,6 @@ class Appwrite extends Platform $this->addModule(new Sites\Module()); $this->addModule(new Console\Module()); $this->addModule(new Proxy\Module()); - $this->addModule(new Storage\Module()); + $this->addModule(new Tokens\Module()); } } From 972cb9c6a54053d6ac16e24705293307533902f6 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 1 May 2025 12:58:20 +0530 Subject: [PATCH 20/21] add: `$permissions` to the `resourceToken` response modal. --- src/Appwrite/Utopia/Response/Model/ResourceToken.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Appwrite/Utopia/Response/Model/ResourceToken.php b/src/Appwrite/Utopia/Response/Model/ResourceToken.php index b9915a34d5..083a3e5f2d 100644 --- a/src/Appwrite/Utopia/Response/Model/ResourceToken.php +++ b/src/Appwrite/Utopia/Response/Model/ResourceToken.php @@ -22,6 +22,13 @@ class ResourceToken extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('$permissions', [ + 'type' => self::TYPE_STRING, + 'description' => 'Token permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', + 'default' => '', + 'example' => ['read("any")'], + 'array' => true, + ]) ->addRule('resourceId', [ 'type' => self::TYPE_STRING, 'description' => 'Resource ID.', From 6dbeff01113a75879ea007d181b01de159c0b17c Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 1 May 2025 13:20:29 +0530 Subject: [PATCH 21/21] update: specs for console sdk. --- app/config/specs/open-api3-latest-client.json | 27 +++++++++++++------ .../specs/open-api3-latest-console.json | 27 +++++++++++++------ app/config/specs/open-api3-latest-server.json | 27 +++++++++++++------ app/config/specs/swagger2-latest-client.json | 27 +++++++++++++------ app/config/specs/swagger2-latest-console.json | 27 +++++++++++++------ app/config/specs/swagger2-latest-server.json | 27 +++++++++++++------ 6 files changed, 114 insertions(+), 48 deletions(-) diff --git a/app/config/specs/open-api3-latest-client.json b/app/config/specs/open-api3-latest-client.json index a539e26f03..ed31be27a5 100644 --- a/app/config/specs/open-api3-latest-client.json +++ b/app/config/specs/open-api3-latest-client.json @@ -9483,34 +9483,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/open-api3-latest-console.json b/app/config/specs/open-api3-latest-console.json index 4b9334cd68..5354c2f9de 100644 --- a/app/config/specs/open-api3-latest-console.json +++ b/app/config/specs/open-api3-latest-console.json @@ -38045,34 +38045,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/open-api3-latest-server.json b/app/config/specs/open-api3-latest-server.json index c5e83e4c0b..19b94378c2 100644 --- a/app/config/specs/open-api3-latest-server.json +++ b/app/config/specs/open-api3-latest-server.json @@ -28004,34 +28004,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/swagger2-latest-client.json b/app/config/specs/swagger2-latest-client.json index dfe5820976..e24eb83999 100644 --- a/app/config/specs/swagger2-latest-client.json +++ b/app/config/specs/swagger2-latest-client.json @@ -9586,34 +9586,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/swagger2-latest-console.json b/app/config/specs/swagger2-latest-console.json index 33e12bbef5..3fd625b154 100644 --- a/app/config/specs/swagger2-latest-console.json +++ b/app/config/specs/swagger2-latest-console.json @@ -38290,34 +38290,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/swagger2-latest-server.json b/app/config/specs/swagger2-latest-server.json index dd44d76a76..61a75e3dec 100644 --- a/app/config/specs/swagger2-latest-server.json +++ b/app/config/specs/swagger2-latest-server.json @@ -28318,34 +28318,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": {