feat: add new column imageTransformation to toggle filePreview

This commit is contained in:
Mustaque Ahmed
2025-10-12 20:04:58 +05:30
parent 852f06cc28
commit 3a51e37657
6 changed files with 261 additions and 9 deletions
+11
View File
@@ -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,
+17 -5
View File
@@ -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());
+14 -4
View File
@@ -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());
+2
View File
@@ -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',
])));
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace Appwrite\Migration\Version;
use Appwrite\Migration\Migration;
use Throwable;
use Utopia\CLI\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
class V23 extends Migration
{
/**
* @throws Throwable
*/
public function execute(): void
{
/**
* Disable SubQueries for Performance.
*/
foreach (['subQueryIndexes', 'subQueryPlatforms', 'subQueryDomains', 'subQueryKeys', 'subQueryWebhooks', 'subQuerySessions', 'subQueryTokens', 'subQueryMemberships', 'subQueryVariables', 'subQueryChallenges', 'subQueryProjectVariables', 'subQueryTargets', 'subQueryTopicTargets'] as $name) {
Database::addFilter(
$name,
fn () => 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()}");
}
});
}
}
@@ -0,0 +1,140 @@
<?php
namespace Tests\E2E\Services\Storage;
use CURLFile;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideClient;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
class StorageImageTransformationsTest extends Scope
{
use ProjectCustom;
use SideClient;
public function testImageTransformationsDisabledBlocksPreviewForAllUsers(): array
{
// Create a bucket with imageTransformations disabled
$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'],
], [
'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']);
}
}