From 3a51e3765763e2b434e877c2b1e95544ab489a34 Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Sun, 12 Oct 2025 20:04:58 +0530 Subject: [PATCH] feat: add new column `imageTransformation` to toggle filePreview --- app/config/collections/common.php | 11 ++ app/controllers/api/storage.php | 22 ++- app/controllers/shared/api.php | 18 ++- app/http.php | 2 + src/Appwrite/Migration/Version/V23.php | 77 ++++++++++ .../StorageImageTransformationsTest.php | 140 ++++++++++++++++++ 6 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 src/Appwrite/Migration/Version/V23.php create mode 100644 tests/e2e/Services/Storage/StorageImageTransformationsTest.php diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 6de7eb224b..704415aabb 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -1527,6 +1527,17 @@ return [ 'required' => true, 'array' => false, ], + [ + '$id' => ID::custom('imageTransformations'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + 'default' => true, + ], [ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index c6e242296b..93e285a731 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -85,10 +85,11 @@ App::post('/v1/storage/buckets') ->param('compression', Compression::NONE, new WhiteList([Compression::NONE, Compression::GZIP, Compression::ZSTD], true), 'Compression algorithm choosen for compression. Can be one of ' . Compression::NONE . ', [' . Compression::GZIP . '](https://en.wikipedia.org/wiki/Gzip), or [' . Compression::ZSTD . '](https://en.wikipedia.org/wiki/Zstd), For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' compression is skipped even if it\'s enabled', true) ->param('encryption', true, new Boolean(true), 'Is encryption enabled? For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' encryption is skipped even if it\'s enabled', true) ->param('antivirus', true, new Boolean(true), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS, 0) . ' AntiVirus scanning is skipped even if it\'s enabled', true) + ->param('imageTransformations', true, new Boolean(true), 'Enable image transformations for this bucket. When set to false, image preview/transform routes will be disabled for non-privileged users.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, int $maximumFileSize, array $allowedFileExtensions, ?string $compression, ?bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $queueForEvents) { + ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, int $maximumFileSize, array $allowedFileExtensions, ?string $compression, ?bool $encryption, bool $antivirus, bool $imageTransformations, Response $response, Database $dbForProject, Event $queueForEvents) { $bucketId = $bucketId === 'unique()' ? ID::unique() : $bucketId; @@ -128,7 +129,6 @@ App::post('/v1/storage/buckets') 'orders' => $index['orders'], ]); } - $dbForProject->createDocument('buckets', new Document([ '$id' => $bucketId, '$collection' => 'buckets', @@ -141,6 +141,7 @@ App::post('/v1/storage/buckets') 'compression' => $compression, 'encryption' => $encryption, 'antivirus' => $antivirus, + 'imageTransformations' => $imageTransformations, 'search' => implode(' ', [$bucketId, $name]), ])); @@ -295,10 +296,11 @@ App::put('/v1/storage/buckets/:bucketId') ->param('compression', Compression::NONE, new WhiteList([Compression::NONE, Compression::GZIP, Compression::ZSTD], true), 'Compression algorithm choosen for compression. Can be one of ' . Compression::NONE . ', [' . Compression::GZIP . '](https://en.wikipedia.org/wiki/Gzip), or [' . Compression::ZSTD . '](https://en.wikipedia.org/wiki/Zstd), For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' compression is skipped even if it\'s enabled', true) ->param('encryption', true, new Boolean(true), 'Is encryption enabled? For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' encryption is skipped even if it\'s enabled', true) ->param('antivirus', true, new Boolean(true), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS, 0) . ' AntiVirus scanning is skipped even if it\'s enabled', true) + ->param('imageTransformations', true, new Boolean(true), 'Enable image transformations for this bucket. When set to false, image preview/transform routes will be disabled for non-privileged users.', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, ?int $maximumFileSize, array $allowedFileExtensions, ?string $compression, ?bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $queueForEvents) { + ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, ?int $maximumFileSize, array $allowedFileExtensions, ?string $compression, ?bool $encryption, bool $antivirus, bool $imageTransformations, Response $response, Database $dbForProject, Event $queueForEvents) { $bucket = $dbForProject->getDocument('buckets', $bucketId); if ($bucket->isEmpty()) { @@ -312,6 +314,7 @@ App::put('/v1/storage/buckets/:bucketId') $encryption ??= $bucket->getAttribute('encryption', true); $antivirus ??= $bucket->getAttribute('antivirus', true); $compression ??= $bucket->getAttribute('compression', Compression::NONE); + $imageTransformations ??= $bucket->getAttribute('imageTransformations', true); // Map aggregate permissions into the multiple permissions they represent. $permissions = Permission::aggregate($permissions); @@ -323,6 +326,7 @@ App::put('/v1/storage/buckets/:bucketId') ->setAttribute('allowedFileExtensions', $allowedFileExtensions) ->setAttribute('fileSecurity', $fileSecurity) ->setAttribute('enabled', $enabled) + ->setAttribute('imageTransformations', $imageTransformations) ->setAttribute('encryption', $encryption) ->setAttribute('compression', $compression) ->setAttribute('antivirus', $antivirus)); @@ -985,6 +989,13 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') throw new Exception(Exception::USER_UNAUTHORIZED); } + // Check bucket-level image transformations flag + $allowImageTransformations = $bucket->getAttribute('imageTransformations', true); + if (!$allowImageTransformations && !$isToken) { + // Image transformations are disabled for this bucket + throw new Exception(Exception::USER_UNAUTHORIZED); + } + if ($fileSecurity && !$valid && !$isToken) { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); } else { @@ -1110,8 +1121,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; - //Do not update transformedAt if it's a console user - if (!Auth::isPrivilegedUser(Authorization::getRoles())) { + // Update transformedAt only when image transformations are allowed + $allowImageTransformations = $bucket->getAttribute('imageTransformations', true); + if ($allowImageTransformations) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 86fb1e5822..d45e55042e 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -557,7 +557,8 @@ App::init() if ($useCache) { $route = $utopia->match($request); $isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview'; - $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && !Auth::isPrivilegedUser(Authorization::getRoles()); + // Plan-level disable (legacy) - -1 means disabled + $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1; $key = $request->cacheIdentifier(); $cacheLog = Authorization::skip(fn () => $dbForProject->getDocument('cache', $key)); @@ -571,10 +572,18 @@ App::init() $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); $type = $parts[0] ?? null; - if ($type === 'bucket' && (!$isImageTransformation || !$isDisabled)) { + if ($type === 'bucket') { $bucketId = $parts[1] ?? null; $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + // If bucket explicitly disables image transformations, set disabled flag + $bucketImageTransformations = $bucket->getAttribute('imageTransformations', true); + $isDisabled = $isDisabled || !$bucketImageTransformations; + + // Only proceed for preview when not disabled; other routes unaffected + if ($isImageTransformation && $isDisabled) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); @@ -605,8 +614,9 @@ App::init() if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - //Do not update transformedAt if it's a console user - if (!Auth::isPrivilegedUser(Authorization::getRoles())) { + // Update transformedAt only when bucket and plan allow image transformations + $allowImageTransformations = $bucket->getAttribute('imageTransformations', true); + if ($allowImageTransformations) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); diff --git a/app/http.php b/app/http.php index 30f4013821..77f385bdd2 100644 --- a/app/http.php +++ b/app/http.php @@ -272,6 +272,7 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg 'encryption' => true, 'antivirus' => true, 'fileSecurity' => true, + 'imageTransformations' => true, '$permissions' => [ Permission::create(Role::any()), Permission::read(Role::any()), @@ -325,6 +326,7 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg 'encryption' => false, 'antivirus' => false, 'fileSecurity' => true, + 'imageTransformations' => true, '$permissions' => [], 'search' => 'buckets Screenshots', ]))); diff --git a/src/Appwrite/Migration/Version/V23.php b/src/Appwrite/Migration/Version/V23.php new file mode 100644 index 0000000000..2683632b4e --- /dev/null +++ b/src/Appwrite/Migration/Version/V23.php @@ -0,0 +1,77 @@ + null, + fn () => [] + ); + } + + Console::log('Migrating Project: ' . $this->project->getAttribute('name') . ' (' . $this->project->getId() . ')'); + $this->dbForProject->setNamespace("_{$this->project->getSequence()}"); + + if ($this->project->getSequence() !== 'console') { + Console::info('Migrating Buckets'); + // Ensure attribute exists on buckets collection + try { + $this->createAttributeFromCollection($this->dbForProject, 'buckets', 'imageTransformations'); + } catch (\Throwable $th) { + Console::warning("Failed to create attribute 'imageTransformations' on buckets: {$th->getMessage()}"); + } + + // Ensure attribute exists on bucket files collections (as 'files' attribute) + $this->migrateBuckets(); + } + } + + /** + * Migrating Buckets - set imageTransformations=true when missing. + * + * @return void + */ + private function migrateBuckets(): void + { + $this->dbForProject->forEach('buckets', function (Document $bucket) { + $bucketId = 'bucket_' . $bucket['$sequence']; + + Console::log("Migrating Bucket {$bucketId} {$bucket->getId()} ({$bucket->getAttribute('name')})"); + + try { + // Only set the attribute when it's missing to preserve existing explicit settings + if ($bucket->getAttribute('imageTransformations', null) === null) { + $bucket->setAttribute('imageTransformations', true); + $this->dbForProject->updateDocument('buckets', $bucket->getId(), $bucket); + } + + // Also ensure per-bucket files collections have the attribute created + $bucketFilesCollection = 'bucket_' . $bucket['$sequence']; + try { + $this->createAttributeFromCollection($this->dbForProject, $bucketFilesCollection, 'transformedAt', 'files'); + } catch (\Throwable $th) { + // ignore - attribute may already exist or creation may not be necessary + } + } catch (Throwable $th) { + Console::warning("Failed to update bucket {$bucket->getId()}: {$th->getMessage()}"); + } + }); + } +} diff --git a/tests/e2e/Services/Storage/StorageImageTransformationsTest.php b/tests/e2e/Services/Storage/StorageImageTransformationsTest.php new file mode 100644 index 0000000000..e7028bc05f --- /dev/null +++ b/tests/e2e/Services/Storage/StorageImageTransformationsTest.php @@ -0,0 +1,140 @@ +client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'ImageTransformDisabled', + 'fileSecurity' => false, + 'imageTransformations' => false, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + $this->assertNotEmpty($bucketId); + + // Upload an image to the bucket + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + ]); + + $this->assertEquals(201, $file['headers']['status-code']); + $fileId = $file['body']['$id']; + $this->assertNotEmpty($fileId); + + // Attempt preview as normal project session user -> should be unauthorized + $preview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(401, $preview['headers']['status-code']); + + // Attempt preview using project API key (previously privileged) -> should also be unauthorized + $previewKey = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(401, $previewKey['headers']['status-code']); + + return ['bucketId' => $bucketId, 'fileId' => $fileId]; + } + + /** + * @depends testImageTransformationsDisabledBlocksPreviewForAllUsers + */ + public function testToggleImageTransformationsEnablesAndDisablesPreview(array $data): void + { + $bucketId = $data['bucketId']; + $fileId = $data['fileId']; + + // Enable image transformations via bucket update + $update = $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'name' => 'ImageTransformDisabled', + 'imageTransformations' => true, + ]); + + $this->assertEquals(200, $update['headers']['status-code']); + + // Now preview should be allowed for session user + $preview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $preview['headers']['status-code']); + + // And allowed for project API key + $previewKey = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $previewKey['headers']['status-code']); + + // Now disable image transformations again + $update2 = $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'name' => 'ImageTransformDisabled', + 'imageTransformations' => false, + ]); + + $this->assertEquals(200, $update2['headers']['status-code']); + + // Preview should now be unauthorized again for session + $preview2 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(401, $preview2['headers']['status-code']); + + // And for API key + $previewKey2 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(401, $previewKey2['headers']['status-code']); + } +}