diff --git a/.env b/.env index 87834e19eb..e29c679a30 100644 --- a/.env +++ b/.env @@ -32,7 +32,7 @@ _APP_SMTP_PORT=1025 _APP_SMTP_SECURE= _APP_SMTP_USERNAME= _APP_SMTP_PASSWORD= -_APP_STORAGE_LIMIT=10000000 +_APP_STORAGE_LIMIT=30000000 _APP_FUNCTIONS_TIMEOUT=900 _APP_FUNCTIONS_CONTAINERS=10 _APP_FUNCTIONS_CPUS=1 diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index ed7034d805..19546bb732 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -24,6 +24,7 @@ use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Validator\Integer; use Utopia\Database\Query; +use Utopia\Storage\Validator\FileExt; App::post('/v1/storage/buckets') ->desc('Create storage bucket') @@ -40,12 +41,12 @@ App::post('/v1/storage/buckets') ->param('name', '', new Text(128), 'Bucket name', false) ->param('read', [], new ArrayList(new Text(64)), 'An array of strings with read permissions. By default no user is granted with any read permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.', true) ->param('write', [], new ArrayList(new Text(64)), 'An array of strings with write permissions. By default no user is granted with any write permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.', true) - ->param('maximumFileSize', 0, new Integer(), 'Maximum file size allowed.', true) - ->param('allowedFileExtensions', ['*'], new ArrayList(new Text(64)), 'Allowed file extensions', true) + ->param('maximumFileSize', (int) App::getEnv('_APP_STORAGE_LIMIT', 0) , new Integer(), 'Maximum file size allowed in bytes. Maximum allowed value is ' . App::getEnv('_APP_STORAGE_LIMIT', 0) . '. For self-hosted setups you can change the max limit by changing the `_APP_STORAGE_LIMIT` environment variable. [Learn more about storage environment variables](docs/environment-variables#storage)', true) + ->param('allowedFileExtensions', [], new ArrayList(new Text(64)), 'Allowed file extensions', true) ->param('enabled', true, new Boolean(), 'Is bucket enabled?', true) ->param('adapter', 'local', new WhiteList(['local']), 'Storage adapter.', true) - ->param('encryption', true, new Boolean(), 'Is encryption enabled?', true) - ->param('antiVirus', true, new Boolean(), 'Is virus scanning enabled?', true) + ->param('encryption', true, new Boolean(), 'Is encryption enabled? For file size above ' . Storage::human(APP_LIMIT_ENCRYPTION) . ' encryption is skipped even if it\'s enabled', true) + ->param('antiVirus', true, new Boolean(), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS) . ' AntiVirus scanning is skipped even if it\'s enabled', true) ->inject('response') ->inject('dbForInternal') ->inject('audits') @@ -151,11 +152,11 @@ App::put('/v1/storage/buckets/:bucketId') ->param('name', null, new Text(128), 'Bucket name', false) ->param('read', null, new ArrayList(new Text(64)), 'An array of strings with read permissions. By default inherits the existing read permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.', true) ->param('write', null, new ArrayList(new Text(64)), 'An array of strings with write permissions. By default inherits the existing write permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.', true) - ->param('maximumFileSize', 0, new Integer(), 'Maximum file size allowed.', true) - ->param('allowedFileExtensions', ['*'], new ArrayList(new Text(64)), 'Allowed file extensions', true) + ->param('maximumFileSize', null, new Integer(), 'Maximum file size allowed in bytes. Maximum allowed value is ' . App::getEnv('_APP_STORAGE_LIMIT', 0) . '. For self hosted version you can change the limit by changing _APP_STORAGE_LIMIT environment variable. [Learn more about storage environment variables](docs/environment-variables#storage)', true) + ->param('allowedFileExtensions', [], new ArrayList(new Text(64)), 'Allowed file extensions', true) ->param('enabled', true, new Boolean(), 'Is bucket enabled?', true) - ->param('encryption', true, new Boolean(), 'Is encryption enabled?', true) - ->param('antiVirus', true, new Boolean(), 'Is virus scanning enabled?', true) + ->param('encryption', true, new Boolean(), 'Is encryption enabled? For file size above ' . Storage::human(APP_LIMIT_ENCRYPTION) . ' encryption is skipped even if it\'s enabled', true) + ->param('antiVirus', true, new Boolean(), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS) . ' AntiVirus scanning is skipped even if it\'s enabled', true) ->inject('response') ->inject('dbForInternal') ->inject('audits') @@ -245,6 +246,644 @@ App::delete('/v1/storage/buckets/:bucketId') $response->noContent(); }); +App::post('/v1/storage/buckets/:bucketId/files') + ->desc('Create File') + ->groups(['api', 'storage']) + ->label('scope', 'files.write') + ->label('event', 'storage.files.create') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'createFile') + ->label('sdk.description', '/docs/references/storage/create-file.md') + ->label('sdk.request.type', 'multipart/form-data') + ->label('sdk.methodType', 'upload') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_FILE) + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('file', [], new File(), 'Binary file.', false) + ->param('read', null, new ArrayList(new Text(64)), 'An array of strings with read permissions. By default only the current user is granted with read permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.', true) + ->param('write', null, new ArrayList(new Text(64)), 'An array of strings with write permissions. By default only the current user is granted with write permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.', true) + ->inject('request') + ->inject('response') + ->inject('dbForInternal') + ->inject('user') + ->inject('audits') + ->inject('usage') + ->action(function ($bucketId, $file, $read, $write, $request, $response, $dbForInternal, $user, $audits, $usage) { + /** @var Utopia\Swoole\Request $request */ + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Database $dbForInternal */ + /** @var Appwrite\Database\Document $user */ + /** @var Appwrite\Event\Event $audits */ + /** @var Appwrite\Event\Event $usage */ + + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + $file = $request->getFiles('file'); + + /* + * Validators + */ + $allowedFileExtensions = $bucket->getAttribute('allowedFileExtensions', []); + $fileExt = new FileExt($allowedFileExtensions); + + $maximumFileSize = $bucket->getAttribute('maximumFileSize', 0); + if($maximumFileSize > (int) App::getEnv('_APP_STORAGE_LIMIT',0)) { + throw new Exception('Error bucket maximum file size is larger than _APP_STORAGE_LIMIT', 500); + } + + $fileSize = new FileSize($maximumFileSize); + $upload = new Upload(); + + if (empty($file)) { + throw new Exception('No file sent', 400); + } + + // Make sure we handle a single file and multiple files the same way + $file['name'] = (\is_array($file['name']) && isset($file['name'][0])) ? $file['name'][0] : $file['name']; + $file['tmp_name'] = (\is_array($file['tmp_name']) && isset($file['tmp_name'][0])) ? $file['tmp_name'][0] : $file['tmp_name']; + $file['size'] = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; + + // Check if file type is allowed (feature for project settings?) + if (!empty($allowedFileExtensions) && !$fileExt->isValid($file['name'])) { + throw new Exception('File extension not allowed', 400); + } + + if (!$fileSize->isValid($file['size'])) { // Check if file size is exceeding allowed limit + throw new Exception('File size not allowed', 400); + } + + $device = Storage::getDevice('files'); + + if (!$upload->isValid($file['tmp_name'])) { + throw new Exception('Invalid file', 403); + } + + // Save to storage + $size = $device->getFileSize($file['tmp_name']); + $path = $device->getPath(\uniqid().'.'.\pathinfo($file['name'], PATHINFO_EXTENSION)); + $path = $bucket->getId() . '/' . $path; + + if (!$device->upload($file['tmp_name'], $path)) { // TODO deprecate 'upload' and replace with 'move' + throw new Exception('Failed moving file', 500); + } + + $mimeType = $device->getFileMimeType($path); // Get mime-type before compression and encryption + + if (App::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antiVirus', true) && $size <= APP_LIMIT_ANTIVIRUS) { + $antiVirus = new Network(App::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), + (int) App::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310)); + + if (!$antiVirus->fileScan($path)) { + $device->delete($path); + throw new Exception('Invalid file', 403); + } + } + + // Compression + $data = $device->read($path); + if($size <= APP_LIMIT_COMPRESSION) { + $compressor = new GZIP(); + $data = $compressor->compress($data); + } + + if($bucket->getAttribute('encryption', true) && $size <= APP_LIMIT_ENCRYPTION) { + $key = App::getEnv('_APP_OPENSSL_KEY_V1'); + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag); + } + + if (!$device->write($path, $data, $mimeType)) { + throw new Exception('Failed to save file', 500); + } + + $sizeActual = $device->getFileSize($path); + + $data = [ + '$read' => (is_null($read) && !$user->isEmpty()) ? ['user:'.$user->getId()] : $read ?? [], // By default set read permissions for user + '$write' => (is_null($write) && !$user->isEmpty()) ? ['user:'.$user->getId()] : $write ?? [], // By default set write permissions for user + 'dateCreated' => \time(), + 'bucketId' => $bucket->getId(), + 'name' => $file['name'], + 'path' => $path, + 'signature' => $device->getFileHash($path), + 'mimeType' => $mimeType, + 'sizeOriginal' => $size, + 'sizeActual' => $sizeActual, + 'algorithm' => empty($compressor) ? '' : $compressor->getName(), + 'comment' => '', + ]; + + if($bucket->getAttribute('encryption', true) && $size <= APP_LIMIT_ENCRYPTION) { + $data['openSSLVersion'] = '1'; + $data['openSSLCipher'] = OpenSSL::CIPHER_AES_128_GCM; + $data['openSSLTag'] = \bin2hex($tag); + $data['openSSLIV'] = \bin2hex($iv); + } + + $file = $dbForInternal->createDocument('files', new Document($data)); + + $audits + ->setParam('event', 'storage.files.create') + ->setParam('resource', 'storage/files/'.$file->getId()) + ; + + $usage + ->setParam('storage', $sizeActual) + ; + + $response->setStatusCode(Response::STATUS_CODE_CREATED); + $response->dynamic2($file, Response::MODEL_FILE); + }); + +App::get('/v1/storage/buckets/:bucketId/files') + ->desc('List Files') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'listFiles') + ->label('sdk.description', '/docs/references/storage/list-files.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_FILE_LIST) + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('limit', 25, new Range(0, 100), 'Results limit value. By default will return maximum 25 results. Maximum of 100 results allowed per request.', true) + ->param('offset', 0, new Range(0, 2000), 'Results offset. The default value is 0. Use this param to manage pagination.', true) + ->param('orderType', 'ASC', new WhiteList(['ASC', 'DESC'], true), 'Order result by ASC or DESC order.', true) + ->inject('response') + ->inject('dbForInternal') + ->action(function ($bucketId, $search, $limit, $offset, $orderType, $response, $dbForInternal) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Database $dbForInternal */ + + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + $queries = [new Query('bucketId', Query::TYPE_EQUAL, [$bucketId])]; + + if($search) { + $queries[] = [new Query('name', Query::TYPE_SEARCH, [$search])]; + } + + $response->dynamic2(new Document([ + 'files' => $dbForInternal->find('files', $queries, $limit, $offset, ['_id'], [$orderType]), + 'sum' => $dbForInternal->count('files', $queries, APP_LIMIT_COUNT), + ]), Response::MODEL_FILE_LIST); + }); + +App::get('/v1/storage/buckets/:bucketId/files/:fileId') + ->desc('Get File') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'getFile') + ->label('sdk.description', '/docs/references/storage/get-file.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_FILE) + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File unique ID.') + ->inject('response') + ->inject('dbForInternal') + ->action(function ($bucketId, $fileId, $response, $dbForInternal) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Database $dbForInternal */ + + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + $file = $dbForInternal->getDocument('files', $fileId); + + if ($file->isEmpty() || $file->getAttribute('bucketId') != $bucketId) { + throw new Exception('File not found', 404); + } + + $response->dynamic2($file, Response::MODEL_FILE); + }); + +App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') + ->desc('Get File Preview') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'getFilePreview') + ->label('sdk.description', '/docs/references/storage/get-file-preview.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_IMAGE) + ->label('sdk.methodType', 'location') + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File unique ID') + ->param('width', 0, new Range(0, 4000), 'Resize preview image width, Pass an integer between 0 to 4000.', true) + ->param('height', 0, new Range(0, 4000), 'Resize preview image height, Pass an integer between 0 to 4000.', true) + ->param('gravity', Image::GRAVITY_CENTER, new WhiteList([Image::GRAVITY_CENTER, Image::GRAVITY_NORTH, Image::GRAVITY_NORTHWEST, Image::GRAVITY_NORTHEAST, Image::GRAVITY_WEST, Image::GRAVITY_EAST, Image::GRAVITY_SOUTHWEST, Image::GRAVITY_SOUTH, Image::GRAVITY_SOUTHEAST]), 'Image crop gravity', true) + ->param('quality', 100, new Range(0, 100), 'Preview image quality. Pass an integer between 0 to 100. Defaults to 100.', true) + ->param('borderWidth', 0, new Range(0, 100), 'Preview image border in pixels. Pass an integer between 0 to 100. Defaults to 0.', true) + ->param('borderColor', '', new HexColor(), 'Preview image border color. Use a valid HEX color, no # is needed for prefix.', true) + ->param('borderRadius', 0, new Range(0, 4000), 'Preview image border radius in pixels. Pass an integer between 0 to 4000.', true) + ->param('opacity', 1, new Range(0,1, Range::TYPE_FLOAT), 'Preview image opacity. Only works with images having an alpha channel (like png). Pass a number between 0 to 1.', true) + ->param('rotation', 0, new Range(0,360), 'Preview image rotation in degrees. Pass an integer between 0 and 360.', true) + ->param('background', '', new HexColor(), 'Preview image background color. Only works with transparent images (png). Use a valid HEX color, no # is needed for prefix.', true) + ->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true) + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('dbForInternal') + ->action(function ($bucketId, $fileId, $width, $height, $gravity, $quality, $borderWidth, $borderColor, $borderRadius, $opacity, $rotation, $background, $output, $request, $response, $project, $dbForInternal) { + /** @var Utopia\Swoole\Request $request */ + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Document $project */ + /** @var Utopia\Database\Database $dbForInternal */ + + $storage = 'files'; + + if (!\extension_loaded('imagick')) { + throw new Exception('Imagick extension is missing', 500); + } + + if (!Storage::exists($storage)) { + throw new Exception('No such storage device', 400); + } + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + if ((\strpos($request->getAccept(), 'image/webp') === false) && ('webp' == $output)) { // Fallback webp to jpeg when no browser support + $output = 'jpg'; + } + + $inputs = Config::getParam('storage-inputs'); + $outputs = Config::getParam('storage-outputs'); + $fileLogos = Config::getParam('storage-logos'); + + $date = \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)).' GMT'; // 45 days cache + $key = \md5($fileId.$width.$height.$quality.$borderWidth.$borderColor.$borderRadius.$opacity.$rotation.$background.$storage.$output); + + $file = $dbForInternal->getDocument('files', $fileId); + + if ($file->isEmpty() || $file->getAttribute('bucketId') != $bucketId) { + throw new Exception('File not found', 404); + } + + $path = $file->getAttribute('path'); + $type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION)); + $algorithm = $file->getAttribute('algorithm'); + $cipher = $file->getAttribute('openSSLCipher'); + $mime = $file->getAttribute('mimeType'); + + if (!\in_array($mime, $inputs)) { + $path = (\array_key_exists($mime, $fileLogos)) ? $fileLogos[$mime] : $fileLogos['default']; + $algorithm = null; + $cipher = null; + $background = (empty($background)) ? 'eceff1' : $background; + $type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION)); + $key = \md5($path.$width.$height.$quality.$borderWidth.$borderColor.$borderRadius.$opacity.$rotation.$background.$storage.$output); + } + + $compressor = new GZIP(); + $device = Storage::getDevice('files'); + + if (!\file_exists($path)) { + throw new Exception('File not found', 404); + } + + $cache = new Cache(new Filesystem(APP_STORAGE_CACHE.'/app-'.$project->getId())); // Limit file number or size + $data = $cache->load($key, 60 * 60 * 24 * 30 * 3 /* 3 months */); + + if ($data) { + $output = (empty($output)) ? $type : $output; + + return $response + ->setContentType((\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']) + ->addHeader('Expires', $date) + ->addHeader('X-Appwrite-Cache', 'hit') + ->send($data) + ; + } + + $source = $device->read($path); + + if (!empty($cipher)) { // Decrypt + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + App::getEnv('_APP_OPENSSL_KEY_V'.$file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if (!empty($algorithm)) { + $source = $compressor->decompress($source); + } + + $image = new Image($source); + + $image->crop((int) $width, (int) $height, $gravity); + + if (!empty($opacity) || $opacity==0) { + $image->setOpacity($opacity); + } + + if (!empty($background)) { + $image->setBackground('#'.$background); + } + + if (!empty($borderWidth) ) { + $image->setBorder($borderWidth, '#'.$borderColor); + } + + if (!empty($borderRadius)) { + $image->setBorderRadius($borderRadius); + } + + if (!empty($rotation)) { + $image->setRotation($rotation); + } + + $output = (empty($output)) ? $type : $output; + + $data = $image->output($output, $quality); + + $cache->save($key, $data); + + $response + ->setContentType($outputs[$output]) + ->addHeader('Expires', $date) + ->addHeader('X-Appwrite-Cache', 'miss') + ->send($data) + ; + + unset($image); + }); + +App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') + ->desc('Get File for Download') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'getFileDownload') + ->label('sdk.description', '/docs/references/storage/get-file-download.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', '*/*') + ->label('sdk.methodType', 'location') + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File unique ID.') + ->inject('response') + ->inject('dbForInternal') + ->action(function ($bucketId, $fileId, $response, $dbForInternal) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Database $dbForInternal */ + + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + $file = $dbForInternal->getDocument('files', $fileId); + + if ($file->isEmpty() || $file->getAttribute('bucketId') != $bucketId) { + throw new Exception('File not found', 404); + } + + $path = $file->getAttribute('path', ''); + + if (!\file_exists($path)) { + throw new Exception('File not found in '.$path, 404); + } + + $device = Storage::getDevice('files'); + + $source = $device->read($path); + if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + App::getEnv('_APP_OPENSSL_KEY_V'.$file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + if(!empty($file->getAttribute('algorithm', ''))) { + $compressor = new GZIP(); + $source = $compressor->decompress($source); + } + + // Response + $response + ->setContentType($file->getAttribute('mimeType')) + ->addHeader('Content-Disposition', 'attachment; filename="'.$file->getAttribute('name', '').'"') + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)).' GMT') // 45 days cache + ->addHeader('X-Peak', \memory_get_peak_usage()) + ->send($source) + ; + }); + +App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') + ->desc('Get File for View') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'getFileView') + ->label('sdk.description', '/docs/references/storage/get-file-view.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', '*/*') + ->label('sdk.methodType', 'location') + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File unique ID.') + ->inject('response') + ->inject('dbForInternal') + ->action(function ($bucketId, $fileId, $response, $dbForInternal) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Database $dbForInternal */ + + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + $file = $dbForInternal->getDocument('files', $fileId); + $mimes = Config::getParam('storage-mimes'); + + if ($file->isEmpty() || $file->getAttribute('bucketId') != $bucketId) { + throw new Exception('File not found', 404); + } + + $path = $file->getAttribute('path', ''); + + if (!\file_exists($path)) { + throw new Exception('File not found in '.$path, 404); + } + + $compressor = new GZIP(); + $device = Storage::getDevice('files'); + + $contentType = 'text/plain'; + + if (\in_array($file->getAttribute('mimeType'), $mimes)) { + $contentType = $file->getAttribute('mimeType'); + } + + $source = $device->read($path); + + if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + App::getEnv('_APP_OPENSSL_KEY_V'.$file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + + $output = $compressor->decompress($source); + $fileName = $file->getAttribute('name', ''); + + // Response + $response + ->setContentType($contentType) + ->addHeader('Content-Security-Policy', 'script-src none;') + ->addHeader('X-Content-Type-Options', 'nosniff') + ->addHeader('Content-Disposition', 'inline; filename="'.$fileName.'"') + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)).' GMT') // 45 days cache + ->addHeader('X-Peak', \memory_get_peak_usage()) + ->send($output) + ; + }); + +App::put('/v1/storage/buckets/:bucketId/files/:fileId') + ->desc('Update File') + ->groups(['api', 'storage']) + ->label('scope', 'files.write') + ->label('event', 'storage.files.update') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'updateFile') + ->label('sdk.description', '/docs/references/storage/update-file.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_FILE) + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File unique ID.') + ->param('read', [], new ArrayList(new Text(64)), 'An array of strings with read permissions. By default no user is granted with any read permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.') + ->param('write', [], new ArrayList(new Text(64)), 'An array of strings with write permissions. By default no user is granted with any write permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.') + ->inject('response') + ->inject('dbForInternal') + ->inject('audits') + ->action(function ($bucketId, $fileId, $read, $write, $response, $dbForInternal, $audits) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Database $dbForInternal */ + /** @var Appwrite\Event\Event $audits */ + + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + $file = $dbForInternal->getDocument('files', $fileId); + + if ($file->isEmpty() || $file->getAttribute('bucketId') != $bucketId) { + throw new Exception('File not found', 404); + } + + $file = $dbForInternal->updateDocument('files', $fileId, $file + ->setAttribute('$read', $read) + ->setAttribute('$write', $write) + ); + + $audits + ->setParam('event', 'storage.files.update') + ->setParam('resource', 'storage/files/'.$file->getId()) + ; + + $response->dynamic2($file, Response::MODEL_FILE); + }); + +App::delete('/v1/storage/buckets/:bucketId/files/:fileId') + ->desc('Delete File') + ->groups(['api', 'storage']) + ->label('scope', 'files.write') + ->label('event', 'storage.files.delete') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'storage') + ->label('sdk.method', 'deleteFile') + ->label('sdk.description', '/docs/references/storage/delete-file.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('bucketId', null, new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File unique ID.') + ->inject('response') + ->inject('dbForInternal') + ->inject('events') + ->inject('audits') + ->inject('usage') + ->action(function ($bucketId, $fileId, $response, $dbForInternal, $events, $audits, $usage) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Database\Database $dbForInternal */ + /** @var Appwrite\Event\Event $events */ + /** @var Appwrite\Event\Event $audits */ + /** @var Appwrite\Event\Event $usage */ + + $bucket = $dbForInternal->getDocument('buckets', $bucketId); + + if($bucket->isEmpty()) { + throw new Exception('Bucket not found', 404); + } + + $file = $dbForInternal->getDocument('files', $fileId); + + if ($file->isEmpty() || $file->getAttribute('bucketId') != $bucketId) { + throw new Exception('File not found', 404); + } + + $device = Storage::getDevice('files'); + + if ($device->delete($file->getAttribute('path', ''))) { + if (!$dbForInternal->deleteDocument('files', $fileId)) { + throw new Exception('Failed to remove file from DB', 500); + } + } + + $audits + ->setParam('event', 'storage.files.delete') + ->setParam('resource', 'storage/files/'.$file->getId()) + ; + + $usage + ->setParam('storage', $file->getAttribute('size', 0) * -1) + ; + + $events + ->setParam('eventData', $response->output2($file, Response::MODEL_FILE)) + ; + + $response->noContent(); + }); + App::post('/v1/storage/files') ->desc('Create File') ->groups(['api', 'storage']) diff --git a/app/init.php b/app/init.php index f79ef3270e..33f8b904da 100644 --- a/app/init.php +++ b/app/init.php @@ -47,6 +47,9 @@ const APP_MODE_ADMIN = 'admin'; const APP_PAGING_LIMIT = 12; const APP_LIMIT_COUNT = 5000; const APP_LIMIT_USERS = 10000; +const APP_LIMIT_ANTIVIRUS = 20971520; //20MB +const APP_LIMIT_ENCRYPTION = 20971520; //20MB +const APP_LIMIT_COMPRESSION = 20971520; //20MB const APP_CACHE_BUSTER = 148; const APP_VERSION_STABLE = '0.8.0'; const APP_STORAGE_UPLOADS = '/storage/uploads'; diff --git a/composer.lock b/composer.lock index 9d12880f2c..ac4b6d810c 100644 --- a/composer.lock +++ b/composer.lock @@ -1607,7 +1607,7 @@ "source": { "type": "git", "url": "https://github.com/lohanidamodar/abuse", - "reference": "351dba60714321fabcd5028fdf7325f18f343018" + "reference": "8486a4f95248e5a9fb1fe9b650278b264857e929" }, "require": { "ext-pdo": "*", @@ -1641,7 +1641,7 @@ "upf", "utopia" ], - "time": "2021-06-13T07:41:20+00:00" + "time": "2021-06-18T06:21:55+00:00" }, { "name": "utopia-php/analytics", @@ -1704,7 +1704,7 @@ "source": { "type": "git", "url": "https://github.com/lohanidamodar/audit", - "reference": "d5591321161b81043c45be3c53ce9dcfa2d81d72" + "reference": "063c1d527776c85d0ab6d8ffcde694f7813170a6" }, "require": { "ext-pdo": "*", @@ -1738,7 +1738,7 @@ "upf", "utopia" ], - "time": "2021-06-14T07:38:18+00:00" + "time": "2021-06-18T06:19:19+00:00" }, { "name": "utopia-php/cache", @@ -2326,16 +2326,16 @@ }, { "name": "utopia-php/swoole", - "version": "0.2.3", + "version": "0.2.4", "source": { "type": "git", "url": "https://github.com/utopia-php/swoole.git", - "reference": "45c42aae7e7d3f9f82bf194c2cfa5499b674aefe" + "reference": "37d8c64b536d6bc7da4f0f5a934a0ec44885abf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/swoole/zipball/45c42aae7e7d3f9f82bf194c2cfa5499b674aefe", - "reference": "45c42aae7e7d3f9f82bf194c2cfa5499b674aefe", + "url": "https://api.github.com/repos/utopia-php/swoole/zipball/37d8c64b536d6bc7da4f0f5a934a0ec44885abf4", + "reference": "37d8c64b536d6bc7da4f0f5a934a0ec44885abf4", "shasum": "" }, "require": { @@ -2376,9 +2376,9 @@ ], "support": { "issues": "https://github.com/utopia-php/swoole/issues", - "source": "https://github.com/utopia-php/swoole/tree/0.2.3" + "source": "https://github.com/utopia-php/swoole/tree/0.2.4" }, - "time": "2021-03-22T22:39:24+00:00" + "time": "2021-06-22T10:49:24+00:00" }, { "name": "utopia-php/system", @@ -5069,20 +5069,21 @@ "type": "github" } ], + "abandoned": true, "time": "2020-09-28T06:45:17+00:00" }, { "name": "sebastian/type", - "version": "2.3.2", + "version": "2.3.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "0d1c587401514d17e8f9258a27e23527cb1b06c1" + "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/0d1c587401514d17e8f9258a27e23527cb1b06c1", - "reference": "0d1c587401514d17e8f9258a27e23527cb1b06c1", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b8cd8a1c753c90bc1a0f5372170e3e489136f914", + "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914", "shasum": "" }, "require": { @@ -5117,7 +5118,7 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/2.3.2" + "source": "https://github.com/sebastianbergmann/type/tree/2.3.4" }, "funding": [ { @@ -5125,7 +5126,7 @@ "type": "github" } ], - "time": "2021-06-04T13:02:07+00:00" + "time": "2021-06-15T12:49:02+00:00" }, { "name": "sebastian/version", @@ -5234,16 +5235,16 @@ }, { "name": "symfony/console", - "version": "v5.3.0", + "version": "v5.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "058553870f7809087fa80fa734704a21b9bcaeb2" + "reference": "649730483885ff2ca99ca0560ef0e5f6b03f2ac1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/058553870f7809087fa80fa734704a21b9bcaeb2", - "reference": "058553870f7809087fa80fa734704a21b9bcaeb2", + "url": "https://api.github.com/repos/symfony/console/zipball/649730483885ff2ca99ca0560ef0e5f6b03f2ac1", + "reference": "649730483885ff2ca99ca0560ef0e5f6b03f2ac1", "shasum": "" }, "require": { @@ -5312,7 +5313,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.3.0" + "source": "https://github.com/symfony/console/tree/v5.3.2" }, "funding": [ { @@ -5328,7 +5329,7 @@ "type": "tidelift" } ], - "time": "2021-05-26T17:43:10+00:00" + "time": "2021-06-12T09:42:48+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5802,16 +5803,16 @@ }, { "name": "symfony/string", - "version": "v5.3.0", + "version": "v5.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "a9a0f8b6aafc5d2d1c116dcccd1573a95153515b" + "reference": "0732e97e41c0a590f77e231afc16a327375d50b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/a9a0f8b6aafc5d2d1c116dcccd1573a95153515b", - "reference": "a9a0f8b6aafc5d2d1c116dcccd1573a95153515b", + "url": "https://api.github.com/repos/symfony/string/zipball/0732e97e41c0a590f77e231afc16a327375d50b0", + "reference": "0732e97e41c0a590f77e231afc16a327375d50b0", "shasum": "" }, "require": { @@ -5865,7 +5866,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.3.0" + "source": "https://github.com/symfony/string/tree/v5.3.2" }, "funding": [ { @@ -5881,7 +5882,7 @@ "type": "tidelift" } ], - "time": "2021-05-26T17:43:10+00:00" + "time": "2021-06-06T09:51:56+00:00" }, { "name": "theseer/tokenizer", diff --git a/tests/e2e/Scopes/SideConsole.php b/tests/e2e/Scopes/SideConsole.php new file mode 100644 index 0000000000..005f2b698b --- /dev/null +++ b/tests/e2e/Scopes/SideConsole.php @@ -0,0 +1,22 @@ + 'http://localhost', + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $this->getRoot()['session'], + ]; + } + + /** + * @return string + */ + public function getSide() + { + return 'console'; + } +} diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 508866b6a1..bf1fd9b287 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -4,11 +4,374 @@ namespace Tests\E2E\Services\Storage; use CURLFile; use Tests\E2E\Client; -use Utopia\Image\Image; trait StorageBase { - public function testCreateFile():array + public function testCreateBucketFile(): array + { + /** + * Test for SUCCESS + */ + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'name' => 'Test Bucket', + 'maximumFileSize' => 2000000, //2MB + 'allowedFileExtensions' => ["jpg", "png"], + 'read' => ['role:all'], + 'write' => ['role:all'], + ]); + $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'], + ], $this->getHeaders()), [ + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'read' => ['role:all'], + 'write' => ['role:all'], + ]); + $this->assertEquals(201, $file['headers']['status-code']); + $this->assertNotEmpty($file['body']['$id']); + $this->assertIsInt($file['body']['dateCreated']); + $this->assertEquals('logo.png', $file['body']['name']); + $this->assertEquals('image/png', $file['body']['mimeType']); + $this->assertEquals(47218, $file['body']['sizeOriginal']); + $this->assertTrue(md5_file(realpath(__DIR__ . '/../../../resources/logo.png')) != $file['body']['signature']); // should validate that the file is encrypted + + /** + * Test for Large File above 20MB + * This should also validate the test for when Bucket encryption + * is disabled as we are using same test + */ + $bucket2 = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'name' => 'Test Bucket 2', + 'read' => ['role:all'], + 'write' => ['role:all'], + ]); + $this->assertEquals(201, $bucket2['headers']['status-code']); + $this->assertNotEmpty($bucket2['body']['$id']); + + $file2 = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucket2['body']['$id'] . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-a/large-file.mp4'), 'video/mp4', 'large-file.mp4'), + 'read' => ['role:all'], + 'write' => ['role:all'], + ]); + + $this->assertEquals(201, $file2['headers']['status-code']); + $this->assertNotEmpty($file2['body']['$id']); + $this->assertIsInt($file2['body']['dateCreated']); + $this->assertEquals('large-file.mp4', $file2['body']['name']); + $this->assertEquals('video/mp4', $file2['body']['mimeType']); + $this->assertEquals(23660615, $file2['body']['sizeOriginal']); + $this->assertEquals(md5_file(realpath(__DIR__ . '/../../../resources/disk-a/large-file.mp4')), $file2['body']['signature']); // should validate that the file is not encrypted + + /** + * Test for FAILURE unknown Bucket + */ + + $res = $this->client->call(Client::METHOD_POST, '/storage/buckets/empty/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'read' => ['role:all'], + 'write' => ['role:all'], + ]); + $this->assertEquals(404, $res['headers']['status-code']); + + /** + * Test for FAILURE file above bucket's file size limit + */ + + $res = $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()), [ + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-b/kitten-1.png'), 'image/png', 'kitten-1.png'), + 'read' => ['role:all'], + 'write' => ['role:all'], + ]); + + $this->assertEquals(400, $res['headers']['status-code']); + $this->assertEquals('File size not allowed', $res['body']['message']); + + /** + * Test for FAILURE unsupported bucket file extension + */ + + $res = $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()), [ + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-a/kitten-3.gif'), 'image/gif', 'kitten-3.gif'), + 'read' => ['role:all'], + 'write' => ['role:all'], + ]); + + $this->assertEquals(400, $res['headers']['status-code']); + $this->assertEquals('File extension not allowed', $res['body']['message']); + + return ['bucketId' => $bucketId, 'fileId' => $file['body']['$id'], 'largeFileId' => $file2['body']['$id'], 'largeBucketId' => $bucket2['body']['$id']]; + } + + /** + * @depends testCreateBucketFile + */ + public function testListBucketFiles(array $data): array + { + /** + * Test for SUCCESS + */ + $files = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $data['bucketId'] . '/files', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $files['headers']['status-code']); + $this->assertGreaterThan(0, $files['body']['sum']); + $this->assertGreaterThan(0, count($files['body']['files'])); + + /** + * Test for FAILURE unknown Bucket + */ + + $files = $this->client->call(Client::METHOD_GET, '/storage/buckets/empty/files', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(404, $files['headers']['status-code']); + + return $data; + } + + /** + * @depends testCreateBucketFile + */ + public function testGetBucketFile(array $data): array + { + $bucketId = $data['bucketId']; + /** + * Test for SUCCESS + */ + $file1 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $data['fileId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $file1['headers']['status-code']); + $this->assertNotEmpty($file1['body']['$id']); + $this->assertIsInt($file1['body']['dateCreated']); + $this->assertEquals('logo.png', $file1['body']['name']); + $this->assertEquals('image/png', $file1['body']['mimeType']); + $this->assertEquals(47218, $file1['body']['sizeOriginal']); + //$this->assertEquals(54944, $file1['body']['sizeActual']); + //$this->assertEquals('gzip', $file1['body']['algorithm']); + //$this->assertEquals('1', $file1['body']['fileOpenSSLVersion']); + //$this->assertEquals('aes-128-gcm', $file1['body']['fileOpenSSLCipher']); + //$this->assertNotEmpty($file1['body']['fileOpenSSLTag']); + //$this->assertNotEmpty($file1['body']['fileOpenSSLIV']); + $this->assertIsArray($file1['body']['$read']); + $this->assertIsArray($file1['body']['$write']); + $this->assertCount(1, $file1['body']['$read']); + $this->assertCount(1, $file1['body']['$write']); + + $file2 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $data['fileId'] . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $file2['headers']['status-code']); + $this->assertEquals('image/png', $file2['headers']['content-type']); + $this->assertNotEmpty($file2['body']); + + //new image preview features + $file3 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $data['fileId'] . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 300, + 'height' => 100, + 'borderRadius' => '50', + 'opacity' => '0.5', + 'output' => 'png', + 'rotation' => '45', + ]); + + $this->assertEquals(200, $file3['headers']['status-code']); + $this->assertEquals('image/png', $file3['headers']['content-type']); + $this->assertNotEmpty($file3['body']); + + $image = new \Imagick(); + $image->readImageBlob($file3['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()); + + $file4 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $data['fileId'] . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 200, + 'height' => 80, + 'borderWidth' => '5', + 'borderColor' => 'ff0000', + 'output' => 'jpg', + ]); + + $this->assertEquals(200, $file4['headers']['status-code']); + $this->assertEquals('image/jpeg', $file4['headers']['content-type']); + $this->assertNotEmpty($file4['body']); + + $image = new \Imagick(); + $image->readImageBlob($file4['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo-after.jpg'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('JPEG', $image->getImageFormat()); + + $file5 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $data['fileId'] . '/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $file5['headers']['status-code']); + $this->assertEquals('attachment; filename="logo.png"', $file5['headers']['content-disposition']); + $this->assertEquals('image/png', $file5['headers']['content-type']); + $this->assertNotEmpty($file5['body']); + + $file6 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $data['fileId'] . '/view', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $file6['headers']['status-code']); + $this->assertEquals('image/png', $file6['headers']['content-type']); + $this->assertNotEmpty($file6['body']); + + /** + * Test large files decompress successfully + */ + $file7 = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $data['largeBucketId'] . '/files/' . $data['largeFileId'] . '/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $fileData = $file7['body']; + $this->assertEquals(200, $file7['headers']['status-code']); + $this->assertEquals('attachment; filename="large-file.mp4"', $file7['headers']['content-disposition']); + $this->assertEquals('video/mp4', $file7['headers']['content-type']); + $this->assertNotEmpty($fileData); + $this->assertEquals(md5_file(realpath(__DIR__ . '/../../../resources/disk-a/large-file.mp4')), md5($fileData)); // validate the file is downloaded correctly + + /** + * Test for FAILURE unknown Bucket + */ + + $file8 = $this->client->call(Client::METHOD_GET, '/storage/buckets/empty/files/' . $data['fileId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $file8['headers']['status-code']); + + return $data; + } + + /** + * @depends testCreateBucketFile + */ + public function testUpdateBucketFile(array $data): array + { + /** + * Test for SUCCESS + */ + $file = $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $data['bucketId'] . '/files/' . $data['fileId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'read' => ['role:all', 'user:x'], + 'write' => ['role:all', 'user:x'], + ]); + + $this->assertEquals(200, $file['headers']['status-code']); + $this->assertNotEmpty($file['body']['$id']); + $this->assertIsInt($file['body']['dateCreated']); + $this->assertEquals('logo.png', $file['body']['name']); + $this->assertEquals('image/png', $file['body']['mimeType']); + $this->assertEquals(47218, $file['body']['sizeOriginal']); + //$this->assertEquals(54944, $file['body']['sizeActual']); + //$this->assertEquals('gzip', $file['body']['algorithm']); + //$this->assertEquals('1', $file['body']['fileOpenSSLVersion']); + //$this->assertEquals('aes-128-gcm', $file['body']['fileOpenSSLCipher']); + //$this->assertNotEmpty($file['body']['fileOpenSSLTag']); + //$this->assertNotEmpty($file['body']['fileOpenSSLIV']); + $this->assertIsArray($file['body']['$read']); + $this->assertIsArray($file['body']['$write']); + $this->assertCount(2, $file['body']['$read']); + $this->assertCount(2, $file['body']['$write']); + + /** + * Test for FAILURE unknown Bucket + */ + + $file = $this->client->call(Client::METHOD_PUT, '/storage/buckets/empty/files/' . $data['fileId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'read' => ['role:all', 'user:x'], + 'write' => ['role:all', 'user:x'], + ]); + + $this->assertEquals(404, $file['headers']['status-code']); + + return $data; + } + + /** + * @depends testUpdateBucketFile + */ + public function testDeleteBucketFile(array $data): array + { + /** + * Test for SUCCESS + */ + $file = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $data['bucketId'] . '/files/' . $data['fileId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $file['headers']['status-code']); + $this->assertEmpty($file['body']); + + $file = $this->client->call(Client::METHOD_GET, '/storage/files/' . $data['fileId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $file['headers']['status-code']); + + /** + * Test for FAILURE + */ + + return $data; + } + + public function testCreateFile(): array { /** * Test for SUCCESS @@ -34,11 +397,11 @@ trait StorageBase */ return ['fileId' => $file['body']['$id']]; } - + /** * @depends testCreateFile */ - public function testGetFile(array $data):array + public function testGetFile(array $data): array { /** * Test for SUCCESS @@ -73,7 +436,7 @@ trait StorageBase $this->assertEquals(200, $file2['headers']['status-code']); $this->assertEquals('image/png', $file2['headers']['content-type']); $this->assertNotEmpty($file2['body']); - + //new image preview features $file3 = $this->client->call(Client::METHOD_GET, '/storage/files/' . $data['fileId'] . '/preview', array_merge([ 'content-type' => 'application/json', @@ -86,7 +449,6 @@ trait StorageBase 'output' => 'png', 'rotation' => '45', ]); - $this->assertEquals(200, $file3['headers']['status-code']); $this->assertEquals('image/png', $file3['headers']['content-type']); @@ -110,11 +472,11 @@ trait StorageBase 'borderColor' => 'ff0000', 'output' => 'jpg', ]); - + $this->assertEquals(200, $file4['headers']['status-code']); $this->assertEquals('image/jpeg', $file4['headers']['content-type']); $this->assertNotEmpty($file4['body']); - + $image = new \Imagick(); $image->readImageBlob($file4['body']); $original = new \Imagick(__DIR__ . '/../../../resources/logo-after.jpg'); @@ -148,11 +510,11 @@ trait StorageBase return $data; } - + /** * @depends testGetFile */ - public function testListFiles(array $data):array + public function testListFiles(array $data): array { /** * Test for SUCCESS @@ -169,14 +531,14 @@ trait StorageBase /** * Test for FAILURE */ - + return $data; } /** * @depends testListFiles */ - public function testUpdateFile(array $data):array + public function testUpdateFile(array $data): array { /** * Test for SUCCESS @@ -205,7 +567,7 @@ trait StorageBase $this->assertIsArray($file['body']['$write']); $this->assertCount(1, $file['body']['$read']); $this->assertCount(1, $file['body']['$write']); - + /** * Test for FAILURE */ @@ -216,7 +578,7 @@ trait StorageBase /** * @depends testUpdateFile */ - public function testDeleteFile(array $data):array + public function testDeleteFile(array $data): array { /** * Test for SUCCESS @@ -235,11 +597,11 @@ trait StorageBase ], $this->getHeaders())); $this->assertEquals(404, $file['headers']['status-code']); - + /** * Test for FAILURE */ - + return $data; } -} \ No newline at end of file +} diff --git a/tests/e2e/Services/Storage/StorageConsoleClientTest.php b/tests/e2e/Services/Storage/StorageConsoleClientTest.php index b2c9582960..4d207d402b 100644 --- a/tests/e2e/Services/Storage/StorageConsoleClientTest.php +++ b/tests/e2e/Services/Storage/StorageConsoleClientTest.php @@ -4,11 +4,11 @@ namespace Tests\E2E\Services\Storage; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\ProjectConsole; -use Tests\E2E\Scopes\SideClient; +use Tests\E2E\Scopes\SideConsole; class StorageConsoleClientTest extends Scope { use StorageBase; use ProjectConsole; - use SideClient; + use SideConsole; } \ No newline at end of file diff --git a/tests/resources/disk-a/large-file.mp4 b/tests/resources/disk-a/large-file.mp4 new file mode 100644 index 0000000000..6fa30f44ba Binary files /dev/null and b/tests/resources/disk-a/large-file.mp4 differ