mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6820b8f362 | ||
|
|
52b09c4e4c | ||
|
|
1103c9334d | ||
|
|
931baeae14 | ||
|
|
528866b8b6 | ||
|
|
2bf0529f85 | ||
|
|
391336e080 | ||
|
|
1f15518f3b | ||
|
|
fd5ee704a9 | ||
|
|
12abcb5806 | ||
|
|
52ee23748d | ||
|
|
367d11785b | ||
|
|
2b9a066be6 | ||
|
|
0ee8c799fa | ||
|
|
40de68a585 | ||
|
|
92fed63c36 | ||
|
|
3a51e37657 |
@@ -1527,6 +1527,17 @@ return [
|
||||
'required' => true,
|
||||
'array' => false,
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('transformations'),
|
||||
'type' => Database::VAR_BOOLEAN,
|
||||
'signed' => true,
|
||||
'size' => 0,
|
||||
'format' => '',
|
||||
'filters' => [],
|
||||
'required' => false,
|
||||
'array' => false,
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('search'),
|
||||
'type' => Database::VAR_STRING,
|
||||
|
||||
@@ -497,6 +497,11 @@ return [
|
||||
'description' => 'The requested file is not publicly readable.',
|
||||
'code' => 403,
|
||||
],
|
||||
Exception::STORAGE_TRANSFORMATIONS_DISABLED => [
|
||||
'name' => Exception::STORAGE_TRANSFORMATIONS_DISABLED,
|
||||
'description' => 'Transformations are disabled for this storage bucket.',
|
||||
'code' => 401,
|
||||
],
|
||||
|
||||
/** Tokens */
|
||||
Exception::TOKEN_NOT_FOUND => [
|
||||
|
||||
@@ -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('transformations', true, new Boolean(true), 'Enable 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 $transformations, 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,
|
||||
'transformations' => $transformations,
|
||||
'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('transformations', true, new Boolean(true), 'Enable 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 $transformations, 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);
|
||||
$transformations ??= $bucket->getAttribute('transformations', 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('transformations', $transformations)
|
||||
->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 transformations flag
|
||||
$allowTransformations = $bucket->getAttribute('transformations', true);
|
||||
if (!$allowTransformations && !$isToken && !$isPrivilegedUser) {
|
||||
// Transformations are disabled for this bucket
|
||||
throw new Exception(Exception::STORAGE_TRANSFORMATIONS_DISABLED);
|
||||
}
|
||||
|
||||
if ($fileSecurity && !$valid && !$isToken) {
|
||||
$file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
|
||||
} else {
|
||||
@@ -1110,8 +1121,12 @@ 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
|
||||
// Do not count Console/privileged requests — we only want to record
|
||||
// transformedAt for actual API uses.
|
||||
$allowTransformations = $bucket->getAttribute('transformations', true);
|
||||
$isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles());
|
||||
if ($allowTransformations && !$isPrivilegedUser) {
|
||||
$transformedAt = $file->getAttribute('transformedAt', '');
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) {
|
||||
$file->setAttribute('transformedAt', DateTime::now());
|
||||
|
||||
@@ -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());
|
||||
$isPlanTransformationsDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && !Auth::isPrivilegedUser(Authorization::getRoles());
|
||||
|
||||
|
||||
$key = $request->cacheIdentifier();
|
||||
$cacheLog = Authorization::skip(fn () => $dbForProject->getDocument('cache', $key));
|
||||
@@ -571,12 +572,27 @@ App::init()
|
||||
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
|
||||
$type = $parts[0] ?? null;
|
||||
|
||||
if ($type === 'bucket' && (!$isImageTransformation || !$isDisabled)) {
|
||||
// Initialize variables for use in response send logic
|
||||
$isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles());
|
||||
$isTransformationsBlocked = false;
|
||||
if ($type === 'bucket') {
|
||||
$bucketId = $parts[1] ?? null;
|
||||
$bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
|
||||
|
||||
// Check if bucket explicitly disables transformations
|
||||
$isBucketTransformationsDisabled = !$bucket->getAttribute('transformations', true);
|
||||
|
||||
// Combined check: disabled if either plan or bucket disables it
|
||||
$isTransformationsBlocked = $isPlanTransformationsDisabled || $isBucketTransformationsDisabled;
|
||||
|
||||
// Evaluate token status for resource token access
|
||||
$isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
|
||||
$isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles());
|
||||
|
||||
// Only proceed for preview when not disabled; other routes unaffected
|
||||
// Skip the block only when transformations remain enabled.
|
||||
if ($isImageTransformation && $isTransformationsBlocked && !$isPrivilegedUser) {
|
||||
throw new Exception(Exception::STORAGE_TRANSFORMATIONS_DISABLED);
|
||||
}
|
||||
|
||||
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAppUser && !$isPrivilegedUser)) {
|
||||
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
|
||||
@@ -605,8 +621,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 transformations
|
||||
$allowTransformations = $bucket->getAttribute('transformations', true);
|
||||
if ($allowTransformations) {
|
||||
$transformedAt = $file->getAttribute('transformedAt', '');
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) {
|
||||
$file->setAttribute('transformedAt', DateTime::now());
|
||||
@@ -619,7 +636,9 @@ App::init()
|
||||
->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp))
|
||||
->addHeader('X-Appwrite-Cache', 'hit')
|
||||
->setContentType($cacheLog->getAttribute('mimeType'));
|
||||
if (!$isImageTransformation || !$isDisabled) {
|
||||
// Determine if user can bypass transformation blocks
|
||||
$canBypassBlock = ($type === 'bucket') && $isPrivilegedUser;
|
||||
if (!$isImageTransformation || !$isTransformationsBlocked || $canBypassBlock) {
|
||||
$response->send($data);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -272,6 +272,7 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg
|
||||
'encryption' => true,
|
||||
'antivirus' => true,
|
||||
'fileSecurity' => true,
|
||||
'transformations' => 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,
|
||||
'transformations' => true,
|
||||
'$permissions' => [],
|
||||
'search' => 'buckets Screenshots',
|
||||
])));
|
||||
|
||||
@@ -148,6 +148,7 @@ class Exception extends \Exception
|
||||
public const STORAGE_INVALID_RANGE = 'storage_invalid_range';
|
||||
public const STORAGE_INVALID_APPWRITE_ID = 'storage_invalid_appwrite_id';
|
||||
public const STORAGE_FILE_NOT_PUBLIC = 'storage_file_not_public';
|
||||
public const STORAGE_TRANSFORMATIONS_DISABLED = 'storage_transformations_disabled';
|
||||
|
||||
/** VCS */
|
||||
public const INSTALLATION_NOT_FOUND = 'installation_not_found';
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Migration\Version;
|
||||
|
||||
use Appwrite\Migration\Migration;
|
||||
use Throwable;
|
||||
use Utopia\CLI\Console;
|
||||
use Utopia\Database\Database;
|
||||
|
||||
class V23 extends Migration
|
||||
{
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
Console::info('Migrating buckets collection to add transformations attribute');
|
||||
$this->migrateBucketsTransformations();
|
||||
|
||||
Console::info('Migration V23 completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate buckets collection to add transformations attribute and clean up imageTransformations
|
||||
*
|
||||
* @return void
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function migrateBucketsTransformations(): void
|
||||
{
|
||||
$this->dbForProject->setNamespace("_{$this->project->getSequence()}");
|
||||
|
||||
try {
|
||||
// First, try to get the buckets collection to see current schema
|
||||
$bucketsCollection = $this->dbForProject->getCollection('buckets');
|
||||
|
||||
if ($bucketsCollection->isEmpty()) {
|
||||
Console::warning('Buckets collection not found, skipping migration');
|
||||
return;
|
||||
}
|
||||
|
||||
$hasImageTransformations = false;
|
||||
$hasTransformations = false;
|
||||
|
||||
// Check what attributes currently exist
|
||||
foreach ($bucketsCollection->getAttribute('attributes', []) as $attribute) {
|
||||
if ($attribute['$id'] === 'imageTransformations') {
|
||||
$hasImageTransformations = true;
|
||||
}
|
||||
if ($attribute['$id'] === 'transformations') {
|
||||
$hasTransformations = true;
|
||||
}
|
||||
}
|
||||
|
||||
Console::log("Current attribute status: imageTransformations=" . ($hasImageTransformations ? 'exists' : 'missing') .
|
||||
", transformations=" . ($hasTransformations ? 'exists' : 'missing'));
|
||||
|
||||
// Scenario 1: Only imageTransformations exists (most common case)
|
||||
if ($hasImageTransformations && !$hasTransformations) {
|
||||
Console::log('Renaming imageTransformations to transformations...');
|
||||
|
||||
// Rename the attribute from imageTransformations to transformations
|
||||
$this->dbForProject->renameAttribute('buckets', 'imageTransformations', 'transformations');
|
||||
|
||||
// Update any indexes that reference the old field name
|
||||
try {
|
||||
$this->dbForProject->deleteIndex('buckets', '_key_imageTransformations');
|
||||
} catch (Throwable $th) {
|
||||
Console::warning("Could not delete old imageTransformations index: {$th->getMessage()}");
|
||||
}
|
||||
|
||||
// Create new index for transformations
|
||||
try {
|
||||
$this->dbForProject->createIndex('buckets', '_key_transformations', Database::INDEX_KEY, ['transformations'], [Database::ORDER_ASC]);
|
||||
} catch (Throwable $th) {
|
||||
Console::warning("Could not create transformations index: {$th->getMessage()}");
|
||||
}
|
||||
|
||||
Console::log('✅ Successfully renamed imageTransformations to transformations');
|
||||
}
|
||||
// Scenario 2: Only transformations exists (already migrated)
|
||||
elseif (!$hasImageTransformations && $hasTransformations) {
|
||||
Console::log('✅ Transformations attribute already exists, no migration needed');
|
||||
}
|
||||
// Scenario 3: Both exist (conflict resolution)
|
||||
elseif ($hasImageTransformations && $hasTransformations) {
|
||||
Console::log('Both attributes exist, removing imageTransformations...');
|
||||
|
||||
// Delete the old imageTransformations attribute
|
||||
try {
|
||||
$this->dbForProject->deleteAttribute('buckets', 'imageTransformations');
|
||||
Console::log('✅ Removed duplicate imageTransformations attribute');
|
||||
} catch (Throwable $th) {
|
||||
Console::warning("Could not remove imageTransformations: {$th->getMessage()}");
|
||||
}
|
||||
|
||||
// Delete old index if it exists
|
||||
try {
|
||||
$this->dbForProject->deleteIndex('buckets', '_key_imageTransformations');
|
||||
} catch (Throwable $th) {
|
||||
Console::warning("Could not delete old imageTransformations index: {$th->getMessage()}");
|
||||
}
|
||||
}
|
||||
// Scenario 4: Neither exists (create fresh)
|
||||
else {
|
||||
Console::log('Creating fresh transformations attribute...');
|
||||
|
||||
// Create the transformations attribute from scratch
|
||||
$this->dbForProject->createAttribute(
|
||||
collection: 'buckets',
|
||||
id: 'transformations',
|
||||
type: Database::VAR_BOOLEAN,
|
||||
size: 0,
|
||||
required: false,
|
||||
default: true,
|
||||
signed: true,
|
||||
array: false,
|
||||
format: '',
|
||||
filters: []
|
||||
);
|
||||
|
||||
// Create index for the new attribute
|
||||
try {
|
||||
$this->dbForProject->createIndex('buckets', '_key_transformations', Database::INDEX_KEY, ['transformations'], [Database::ORDER_ASC]);
|
||||
} catch (Throwable $th) {
|
||||
Console::warning("Could not create transformations index: {$th->getMessage()}");
|
||||
}
|
||||
|
||||
Console::log('✅ Created transformations attribute');
|
||||
}
|
||||
|
||||
// Purge the collection cache to ensure changes are reflected
|
||||
$this->dbForProject->purgeCachedCollection('buckets');
|
||||
|
||||
// Verify all existing buckets have the transformations field with a default value
|
||||
Console::log('Ensuring all existing buckets have transformations field...');
|
||||
|
||||
foreach ($this->documentsIterator('buckets') as $bucket) {
|
||||
$transformationsValue = $bucket->getAttribute('transformations');
|
||||
|
||||
// If transformations field is missing or null, set default value
|
||||
if (is_null($transformationsValue)) {
|
||||
Console::log("Setting default transformations=true for bucket: {$bucket->getId()}");
|
||||
|
||||
$bucket->setAttribute('transformations', true);
|
||||
$this->dbForProject->updateDocument('buckets', $bucket->getId(), $bucket);
|
||||
}
|
||||
}
|
||||
|
||||
Console::log('✅ Buckets transformations migration completed successfully');
|
||||
|
||||
} catch (Throwable $th) {
|
||||
Console::error("Buckets transformations migration failed: {$th->getMessage()}");
|
||||
throw $th;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1386,4 +1386,194 @@ class StorageCustomClientTest extends Scope
|
||||
$this->assertStringContainsString('users', $file['body']['message']);
|
||||
$this->assertStringContainsString('user:' . $this->getUser()['$id'], $file['body']['message']);
|
||||
}
|
||||
|
||||
public function testTransformationsDisabledBlocksPreviewForAllUsers(): array
|
||||
{
|
||||
// Create a bucket with transformations 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' => 'TransformDisabled',
|
||||
'fileSecurity' => false,
|
||||
'transformations' => 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 testTransformationsDisabledBlocksPreviewForAllUsers
|
||||
*/
|
||||
public function testToggleTransformationsEnablesAndDisablesPreview(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' => 'TransformDisabled',
|
||||
'transformations' => 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' => 'TransformDisabled',
|
||||
'transformations' => 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']);
|
||||
}
|
||||
|
||||
public function testConsoleClientBypassesTransformationRestrictions(): void
|
||||
{
|
||||
// Create a fresh bucket with transformations explicitly 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' => 'ConsoleClientTestBucket',
|
||||
'fileSecurity' => false,
|
||||
'transformations' => 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);
|
||||
|
||||
// Verify regular users cannot access preview with transformations disabled
|
||||
$userPreview = $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, $userPreview['headers']['status-code']);
|
||||
$this->assertEquals('storage_transformations_disabled', $userPreview['body']['type']);
|
||||
$this->assertStringContainsString('Transformations are disabled', $userPreview['body']['message']);
|
||||
|
||||
// Console Client request with admin mode should work despite transformations being disabled
|
||||
$consolePreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-mode' => 'admin',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $consolePreview['headers']['status-code']);
|
||||
$this->assertStringStartsWith('image/', $consolePreview['headers']['content-type']);
|
||||
|
||||
// Console Client should also support image transformations with parameters
|
||||
$consolePreviewWithTransforms = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview?width=100&height=100&quality=80', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-mode' => 'admin',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $consolePreviewWithTransforms['headers']['status-code']);
|
||||
$this->assertStringStartsWith('image/', $consolePreviewWithTransforms['headers']['content-type']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user