From da7738edaa82002f6ae6bee3191568309309b653 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 29 Dec 2025 01:50:39 +0000 Subject: [PATCH 1/7] Feat: Storage module --- .../Modules/Storage/Http/Buckets/Create.php | 167 +++++++ .../Modules/Storage/Http/Buckets/Delete.php | 89 ++++ .../Storage/Http/Buckets/Files/Create.php | 452 ++++++++++++++++++ .../Storage/Http/Buckets/Files/Delete.php | 17 + .../Http/Buckets/Files/Download/Get.php | 17 + .../Storage/Http/Buckets/Files/Get.php | 91 ++++ .../Http/Buckets/Files/Preview/Get.php | 17 + .../Storage/Http/Buckets/Files/Push/Get.php | 17 + .../Storage/Http/Buckets/Files/Update.php | 17 + .../Storage/Http/Buckets/Files/View/Get.php | 17 + .../Storage/Http/Buckets/Files/XList.php | 151 ++++++ .../Modules/Storage/Http/Buckets/Get.php | 65 +++ .../Modules/Storage/Http/Buckets/Update.php | 131 +++++ .../Modules/Storage/Http/Buckets/XList.php | 117 +++++ .../Modules/Storage/Http/Usage/Get.php | 17 + .../Modules/Storage/Http/Usage/XList.php | 17 + .../Platform/Modules/Storage/Module.php | 14 + .../Modules/Storage/Services/Http.php | 51 ++ 18 files changed, 1464 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Get.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Module.php create mode 100644 src/Appwrite/Platform/Modules/Storage/Services/Http.php diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php new file mode 100644 index 0000000000..a4d28ab487 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php @@ -0,0 +1,167 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/storage/buckets') + ->desc('Create bucket') + ->groups(['api', 'storage']) + ->label('scope', 'buckets.write') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('event', 'buckets.[bucketId].create') + ->label('audits.event', 'bucket.create') + ->label('audits.resource', 'bucket/{response.$id}') + ->label('sdk', new Method( + namespace: 'storage', + group: 'buckets', + name: 'createBucket', + description: '/docs/references/storage/create-bucket.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_BUCKET, + ) + ] + )) + ->param('bucketId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Bucket name') + ->param('permissions', null, new Nullable(new \Utopia\Database\Validator\Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('fileSecurity', false, new Boolean(true), 'Enables configuring permissions for individual file. A user needs one of file or bucket level permissions to access a file. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) + ->param('maximumFileSize', fn(array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn(array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) + ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) + ->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), 'Are image transformations enabled?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + 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; + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions) ?? []; + $compression ??= Compression::NONE; + $encryption ??= true; + try { + $files = (Config::getParam('collections', [])['buckets'] ?? [])['files'] ?? []; + if (empty($files)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Files collection is not configured.'); + } + + $attributes = []; + $indexes = []; + + foreach ($files['attributes'] as $attribute) { + $attributes[] = new Document([ + '$id' => $attribute['$id'], + 'type' => $attribute['type'], + 'size' => $attribute['size'], + 'required' => $attribute['required'], + 'signed' => $attribute['signed'], + 'array' => $attribute['array'], + 'filters' => $attribute['filters'], + 'default' => $attribute['default'] ?? null, + 'format' => $attribute['format'] ?? '' + ]); + } + + foreach ($files['indexes'] as $index) { + $indexes[] = new Document([ + '$id' => $index['$id'], + 'type' => $index['type'], + 'attributes' => $index['attributes'], + 'lengths' => $index['lengths'], + 'orders' => $index['orders'], + ]); + } + + $dbForProject->createDocument('buckets', new Document([ + '$id' => $bucketId, + '$collection' => 'buckets', + '$permissions' => $permissions, + 'name' => $name, + 'maximumFileSize' => $maximumFileSize, + 'allowedFileExtensions' => $allowedFileExtensions, + 'fileSecurity' => $fileSecurity, + 'enabled' => $enabled, + 'compression' => $compression, + 'encryption' => $encryption, + 'antivirus' => $antivirus, + 'transformations' => $transformations, + 'search' => implode(' ', [$bucketId, $name]), + ])); + + $bucket = $dbForProject->getDocument('buckets', $bucketId); + + $dbForProject->createCollection('bucket_' . $bucket->getSequence(), $attributes, $indexes, permissions: $permissions, documentSecurity: $fileSecurity); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_BUCKET_ALREADY_EXISTS); + } + + $queueForEvents + ->setParam('bucketId', $bucket->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($bucket, Response::MODEL_BUCKET); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Delete.php new file mode 100644 index 0000000000..9523f55e12 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Delete.php @@ -0,0 +1,89 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/storage/buckets/:bucketId') + ->desc('Delete bucket') + ->groups(['api', 'storage']) + ->label('scope', 'buckets.write') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('audits.event', 'bucket.delete') + ->label('event', 'buckets.[bucketId].delete') + ->label('audits.resource', 'bucket/{request.bucketId}') + ->label('sdk', new Method( + namespace: 'storage', + group: 'buckets', + name: 'deleteBucket', + description: '/docs/references/storage/delete-bucket.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('bucketId', '', new UID(), 'Bucket unique ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + Response $response, + Database $dbForProject, + DeleteEvent $queueForDeletes, + Event $queueForEvents + ) { + $bucket = $dbForProject->getDocument('buckets', $bucketId); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('buckets', $bucketId)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove bucket from DB'); + } + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($bucket); + + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setPayload($response->output($bucket, Response::MODEL_BUCKET)) + ; + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php new file mode 100644 index 0000000000..8640dedb8b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -0,0 +1,452 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/storage/buckets/:bucketId/files') + ->desc('Create file') + ->groups(['api', 'storage']) + ->label('scope', 'files.write') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('audits.event', 'file.create') + ->label('event', 'buckets.[bucketId].files.[fileId].create') + ->label('audits.resource', 'file/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId},chunkId:{chunkId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'createFile', + description: '/docs/references/storage/create-file.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_FILE, + ) + ], + type: MethodType::UPLOAD, + requestType: ContentType::MULTIPART + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new CustomId(), 'File ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('file', [], new File(), 'Binary file. Appwrite SDKs provide helpers to handle file input. [Learn about file input](https://appwrite.io/docs/products/storage/upload-download#input-file).', skipValidation: true) + ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE])), 'An array of permission strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('queueForEvents') + ->inject('mode') + ->inject('deviceForFiles') + ->inject('deviceForLocal') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + mixed $file, + ?array $permissions, + Request $request, + Response $response, + Database $dbForProject, + Document $user, + Event $queueForEvents, + string $mode, + Device $deviceForFiles, + Device $deviceForLocal + ) { + $bucket = Authorization::skip(fn() => $dbForProject->getDocument('buckets', $bucketId)); + + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $validator = new Authorization(\Utopia\Database\Database::PERMISSION_CREATE); + if (!$validator->isValid($bucket->getCreate())) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + $allowedPermissions = [ + \Utopia\Database\Database::PERMISSION_READ, + \Utopia\Database\Database::PERMISSION_UPDATE, + \Utopia\Database\Database::PERMISSION_DELETE, + ]; + + // Map aggregate permissions to into the set of individual permissions they represent. + $permissions = Permission::aggregate($permissions, $allowedPermissions); + + // Add permissions for current the user if none were provided. + if (\is_null($permissions)) { + $permissions = []; + if (!empty($user->getId()) && !$isPrivilegedUser) { + foreach ($allowedPermissions as $permission) { + $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); + } + } + } + + // Users can only manage their own roles, API keys and Admin users can manage any + $roles = Authorization::getRoles(); + if (!$isAPIKey && !$isPrivilegedUser) { + foreach (\Utopia\Database\Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!Authorization::isRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); + } + } + } + } + + $maximumFileSize = $bucket->getAttribute('maximumFileSize', 0); + if ($maximumFileSize > (int) System::getEnv('_APP_STORAGE_LIMIT', 0)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Maximum bucket file size is larger than _APP_STORAGE_LIMIT'); + } + + $file = $request->getFiles('file'); + + // GraphQL multipart spec adds files with index keys + if (empty($file)) { + $file = $request->getFiles(0); + } + + if (empty($file)) { + throw new Exception(Exception::STORAGE_FILE_EMPTY); + } + + // Make sure we handle a single file and multiple files the same way + $fileName = (\is_array($file['name']) && isset($file['name'][0])) ? $file['name'][0] : $file['name']; + $fileTmpName = (\is_array($file['tmp_name']) && isset($file['tmp_name'][0])) ? $file['tmp_name'][0] : $file['tmp_name']; + $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; + + $contentRange = $request->getHeader('content-range'); + $fileId = $fileId === 'unique()' ? ID::unique() : $fileId; + $chunk = 1; + $chunks = 1; + + if (!empty($contentRange)) { + $start = $request->getContentRangeStart(); + $end = $request->getContentRangeEnd(); + $fileSize = $request->getContentRangeSize(); + $fileId = $request->getHeader('x-appwrite-id', $fileId); + // TODO make `end >= $fileSize` in next breaking version + if (is_null($start) || is_null($end) || is_null($fileSize) || $end > $fileSize) { + throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); + } + + $idValidator = new UID(); + if (!$idValidator->isValid($fileId)) { + throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID); + } + + // TODO remove the condition that checks `$end === $fileSize` in next breaking version + if ($end === $fileSize - 1 || $end === $fileSize) { + //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to -1 notify it's last chunk + $chunks = $chunk = -1; + } else { + // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) + $chunks = (int) ceil($fileSize / ($end + 1 - $start)); + $chunk = (int) ($start / ($end + 1 - $start)) + 1; + } + } + + /** + * Validators + */ + // Check if file type is allowed + $allowedFileExtensions = $bucket->getAttribute('allowedFileExtensions', []); + $fileExt = new FileExt($allowedFileExtensions); + if (!empty($allowedFileExtensions) && !$fileExt->isValid($fileName)) { + throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, 'File extension not allowed'); + } + + // Check if file size is exceeding allowed limit + $fileSizeValidator = new FileSize($maximumFileSize); + if (!$fileSizeValidator->isValid($fileSize)) { + throw new Exception(Exception::STORAGE_INVALID_FILE_SIZE, 'File size not allowed'); + } + + $upload = new Upload(); + if (!$upload->isValid($fileTmpName)) { + throw new Exception(Exception::STORAGE_INVALID_FILE); + } + + // Save to storage + $fileSize ??= $deviceForLocal->getFileSize($fileTmpName); + $path = $deviceForFiles->getPath($fileId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); + $path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root + + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + + $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; + if (!$file->isEmpty()) { + $chunks = $file->getAttribute('chunksTotal', 1); + $uploaded = $file->getAttribute('chunksUploaded', 0); + $metadata = $file->getAttribute('metadata', []); + + if ($chunk === -1) { + $chunk = $chunks; + } + + if ($uploaded === $chunks) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } + } + + $chunksUploaded = $deviceForFiles->upload($fileTmpName, $path, $chunk, $chunks, $metadata); + + if (empty($chunksUploaded)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file'); + } + + if ($chunksUploaded === $chunks) { + if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) { + $antivirus = new Network( + System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), + (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) + ); + + if (!$antivirus->fileScan($path)) { + $deviceForFiles->delete($path); + throw new Exception(Exception::STORAGE_INVALID_FILE); + } + } + + $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption + $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption + $data = ''; + // Compression + $algorithm = $bucket->getAttribute('compression', Compression::NONE); + if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) { + $data = $deviceForFiles->read($path); + switch ($algorithm) { + case Compression::ZSTD: + $compressor = new Zstd(); + break; + case Compression::GZIP: + default: + $compressor = new GZIP(); + break; + } + $data = $compressor->compress($data); + } else { + // reset the algorithm to none as we do not compress the file + // if file size exceedes the APP_STORAGE_READ_BUFFER + // regardless the bucket compression algoorithm + $algorithm = Compression::NONE; + } + + if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { + if (empty($data)) { + $data = $deviceForFiles->read($path); + } + $key = System::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 (!empty($data)) { + if (!$deviceForFiles->write($path, $data, $mimeType)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file'); + } + } + + $sizeActual = $deviceForFiles->getFileSize($path); + + $openSSLVersion = null; + $openSSLCipher = null; + $openSSLTag = null; + $openSSLIV = null; + + if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { + $openSSLVersion = '1'; + $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM; + $openSSLTag = \bin2hex($tag); + $openSSLIV = \bin2hex($iv); + } + + if ($file->isEmpty()) { + $doc = new Document([ + '$id' => $fileId, + '$permissions' => $permissions, + 'bucketId' => $bucket->getId(), + 'bucketInternalId' => $bucket->getSequence(), + 'name' => $fileName, + 'path' => $path, + 'signature' => $fileHash, + 'mimeType' => $mimeType, + 'sizeOriginal' => $fileSize, + 'sizeActual' => $sizeActual, + 'algorithm' => $algorithm, + 'comment' => '', + 'chunksTotal' => $chunks, + 'chunksUploaded' => $chunksUploaded, + 'openSSLVersion' => $openSSLVersion, + 'openSSLCipher' => $openSSLCipher, + 'openSSLTag' => $openSSLTag, + 'openSSLIV' => $openSSLIV, + 'search' => implode(' ', [$fileId, $fileName]), + 'metadata' => $metadata, + ]); + + try { + $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } else { + $file = $file + ->setAttribute('$permissions', $permissions) + ->setAttribute('signature', $fileHash) + ->setAttribute('mimeType', $mimeType) + ->setAttribute('sizeActual', $sizeActual) + ->setAttribute('algorithm', $algorithm) + ->setAttribute('openSSLVersion', $openSSLVersion) + ->setAttribute('openSSLCipher', $openSSLCipher) + ->setAttribute('openSSLTag', $openSSLTag) + ->setAttribute('openSSLIV', $openSSLIV) + ->setAttribute('metadata', $metadata) + ->setAttribute('chunksUploaded', $chunksUploaded); + + /** + * Validate create permission and skip authorization in updateDocument + * Without this, the file creation will fail when user doesn't have update permission + * However as with chunk upload even if we are updating, we are essentially creating a file + * adding it's new chunk so we validate create permission instead of update + */ + $validator = new Authorization(\Utopia\Database\Database::PERMISSION_CREATE); + if (!$validator->isValid($bucket->getCreate())) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + $file = Authorization::skip(fn() => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); + } + } else { + if ($file->isEmpty()) { + $doc = new Document([ + '$id' => ID::custom($fileId), + '$permissions' => $permissions, + 'bucketId' => $bucket->getId(), + 'bucketInternalId' => $bucket->getSequence(), + 'name' => $fileName, + 'path' => $path, + 'signature' => '', + 'mimeType' => '', + 'sizeOriginal' => $fileSize, + 'sizeActual' => 0, + 'algorithm' => '', + 'comment' => '', + 'chunksTotal' => $chunks, + 'chunksUploaded' => $chunksUploaded, + 'search' => implode(' ', [$fileId, $fileName]), + 'metadata' => $metadata, + ]); + + try { + $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } else { + $file = $file + ->setAttribute('chunksUploaded', $chunksUploaded) + ->setAttribute('metadata', $metadata); + + /** + * Validate create permission and skip authorization in updateDocument + * Without this, the file creation will fail when user doesn't have update permission + * However as with chunk upload even if we are updating, we are essentially creating a file + * adding it's new chunk so we validate create permission instead of update + */ + $validator = new Authorization(\Utopia\Database\Database::PERMISSION_CREATE); + if (!$validator->isValid($bucket->getCreate())) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + try { + $file = Authorization::skip(fn() => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } + } + + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setParam('fileId', $file->getId()) + ->setContext('bucket', $bucket); + + $metadata = null; // was causing leaks as it was passed by reference + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($file, Response::MODEL_FILE); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php new file mode 100644 index 0000000000..09e019335a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -0,0 +1,17 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId') + ->desc('Get file') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'getFile', + description: '/docs/references/storage/get-file.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_FILE, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('mode') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + Response $response, + Database $dbForProject, + string $mode + ) { + $bucket = Authorization::skip(fn() => $dbForProject->getDocument('buckets', $bucketId)); + + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(\Utopia\Database\Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($fileSecurity && !$valid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $response->dynamic($file, Response::MODEL_FILE); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php new file mode 100644 index 0000000000..5f33f9f323 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -0,0 +1,17 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files') + ->desc('List files') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'listFiles', + description: '/docs/references/storage/list-files.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_FILE_LIST, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('queries', [], new Files(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Files::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('mode') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + array $queries, + string $search, + bool $includeTotal, + Response $response, + Database $dbForProject, + string $mode + ) { + $bucket = Authorization::skip(fn() => $dbForProject->getDocument('buckets', $bucketId)); + + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(\Utopia\Database\Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + $queries = Query::parseQueries($queries); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $fileId = $cursor->getValue(); + + if ($fileSecurity && !$valid) { + $cursorDocument = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + $cursorDocument = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "File '{$fileId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + if ($fileSecurity && !$valid) { + $files = $dbForProject->find('bucket_' . $bucket->getSequence(), $queries); + $total = $includeTotal ? $dbForProject->count('bucket_' . $bucket->getSequence(), $filterQueries, APP_LIMIT_COUNT) : 0; + } else { + $files = Authorization::skip(fn() => $dbForProject->find('bucket_' . $bucket->getSequence(), $queries)); + $total = $includeTotal ? Authorization::skip(fn() => $dbForProject->count('bucket_' . $bucket->getSequence(), $filterQueries, APP_LIMIT_COUNT)) : 0; + } + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $response->dynamic(new Document([ + 'files' => $files, + 'total' => $total, + ]), Response::MODEL_FILE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Get.php new file mode 100644 index 0000000000..dd14feef6e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Get.php @@ -0,0 +1,65 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId') + ->desc('Get bucket') + ->groups(['api', 'storage']) + ->label('scope', 'buckets.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: 'buckets', + name: 'getBucket', + description: '/docs/references/storage/get-bucket.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_BUCKET, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Bucket unique ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + Response $response, + Database $dbForProject + ) { + $bucket = $dbForProject->getDocument('buckets', $bucketId); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $response->dynamic($bucket, Response::MODEL_BUCKET); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php new file mode 100644 index 0000000000..9f83479671 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php @@ -0,0 +1,131 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/storage/buckets/:bucketId') + ->desc('Update bucket') + ->groups(['api', 'storage']) + ->label('scope', 'buckets.write') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('event', 'buckets.[bucketId].update') + ->label('audits.event', 'bucket.update') + ->label('audits.resource', 'bucket/{response.$id}') + ->label('sdk', new Method( + namespace: 'storage', + group: 'buckets', + name: 'updateBucket', + description: '/docs/references/storage/update-bucket.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_BUCKET, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Bucket unique ID.') + ->param('name', null, new Text(128), 'Bucket name', false) + ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('fileSecurity', false, new Boolean(true), 'Enables configuring permissions for individual file. A user needs one of file or bucket level permissions to access a file. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) + ->param('maximumFileSize', fn(array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn(array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) + ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) + ->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), 'Are image transformations enabled?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + 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()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $permissions ??= $bucket->getPermissions(); + $maximumFileSize ??= $bucket->getAttribute('maximumFileSize', (int) System::getEnv('_APP_STORAGE_LIMIT', 0)); + $allowedFileExtensions ??= $bucket->getAttribute('allowedFileExtensions', []); + $enabled ??= $bucket->getAttribute('enabled', true); + $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); + + $bucket = $dbForProject->updateDocument('buckets', $bucket->getId(), $bucket + ->setAttribute('name', $name) + ->setAttribute('$permissions', $permissions) + ->setAttribute('maximumFileSize', $maximumFileSize) + ->setAttribute('allowedFileExtensions', $allowedFileExtensions) + ->setAttribute('fileSecurity', $fileSecurity) + ->setAttribute('enabled', $enabled) + ->setAttribute('encryption', $encryption) + ->setAttribute('compression', $compression) + ->setAttribute('antivirus', $antivirus) + ->setAttribute('transformations', $transformations)); + + $dbForProject->updateCollection('bucket_' . $bucket->getSequence(), $permissions, $fileSecurity); + + $queueForEvents + ->setParam('bucketId', $bucket->getId()); + + $response->dynamic($bucket, Response::MODEL_BUCKET); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php new file mode 100644 index 0000000000..74f12852be --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php @@ -0,0 +1,117 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets') + ->desc('List buckets') + ->groups(['api', 'storage']) + ->label('scope', 'buckets.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: 'buckets', + name: 'listBuckets', + description: '/docs/references/storage/list-buckets.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_BUCKET_LIST, + ) + ] + )) + ->param('queries', [], new Buckets(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Buckets::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + array $queries, + string $search, + bool $includeTotal, + Response $response, + Database $dbForProject + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $bucketId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('buckets', $bucketId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Bucket '{$bucketId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + try { + $buckets = $dbForProject->find('buckets', $queries); + $total = $includeTotal ? $dbForProject->count('buckets', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + $response->dynamic(new Document([ + 'buckets' => $buckets, + 'total' => $total, + ]), Response::MODEL_BUCKET_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php new file mode 100644 index 0000000000..496ac54582 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php @@ -0,0 +1,17 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Services/Http.php b/src/Appwrite/Platform/Modules/Storage/Services/Http.php new file mode 100644 index 0000000000..e60571eff0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Services/Http.php @@ -0,0 +1,51 @@ +type = Service::TYPE_HTTP; + + // Buckets + $this->addAction(CreateBucket::getName(), new CreateBucket()); + $this->addAction(GetBucket::getName(), new GetBucket()); + $this->addAction(ListBuckets::getName(), new ListBuckets()); + $this->addAction(UpdateBucket::getName(), new UpdateBucket()); + $this->addAction(DeleteBucket::getName(), new DeleteBucket()); + + // Files + $this->addAction(CreateFile::getName(), new CreateFile()); + $this->addAction(GetFile::getName(), new GetFile()); + $this->addAction(ListFiles::getName(), new ListFiles()); + $this->addAction(UpdateFile::getName(), new UpdateFile()); + $this->addAction(DeleteFile::getName(), new DeleteFile()); + $this->addAction(GetFilePreview::getName(), new GetFilePreview()); + $this->addAction(GetFileDownload::getName(), new GetFileDownload()); + $this->addAction(GetFileView::getName(), new GetFileView()); + $this->addAction(GetFileForPush::getName(), new GetFileForPush()); + + // Usage + $this->addAction(ListUsage::getName(), new ListUsage()); + $this->addAction(GetBucketUsage::getName(), new GetBucketUsage()); + } +} From f4f4ad9c7dfc95d31483a280d3f1b3b2599a2657 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 29 Dec 2025 01:51:07 +0000 Subject: [PATCH 2/7] format --- .../Modules/Storage/Http/Buckets/Create.php | 3 +-- .../Storage/Http/Buckets/Files/Create.php | 7 +++---- .../Storage/Http/Buckets/Files/Get.php | 4 ++-- .../Storage/Http/Buckets/Files/Push/Get.php | 2 +- .../Storage/Http/Buckets/Files/XList.php | 8 ++++---- .../Modules/Storage/Http/Buckets/Update.php | 2 +- .../Modules/Storage/Services/Http.php | 20 +++++++++---------- 7 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php index a4d28ab487..00daef061e 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\CustomId; @@ -67,7 +66,7 @@ class Create extends Action ->param('permissions', null, new Nullable(new \Utopia\Database\Validator\Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('fileSecurity', false, new Boolean(true), 'Enables configuring permissions for individual file. A user needs one of file or bucket level permissions to access a file. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) - ->param('maximumFileSize', fn(array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn(array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) + ->param('maximumFileSize', fn (array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn (array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) ->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) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index 8640dedb8b..b2d9af5a08 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files; -use Ahc\Jwt\JWT; use Appwrite\ClamAV\Network; use Appwrite\Event\Event; use Appwrite\Extend\Exception; @@ -108,7 +107,7 @@ class Create extends Action Device $deviceForFiles, Device $deviceForLocal ) { - $bucket = Authorization::skip(fn() => $dbForProject->getDocument('buckets', $bucketId)); + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isAPIKey = User::isApp(Authorization::getRoles()); $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); @@ -384,7 +383,7 @@ class Create extends Action if (!$validator->isValid($bucket->getCreate())) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $file = Authorization::skip(fn() => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); + $file = Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } } else { if ($file->isEmpty()) { @@ -431,7 +430,7 @@ class Create extends Action } try { - $file = Authorization::skip(fn() => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); + $file = Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } catch (NotFoundException) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php index 102ea4d34e..e19fa8ae88 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php @@ -60,7 +60,7 @@ class Get extends Action Database $dbForProject, string $mode ) { - $bucket = Authorization::skip(fn() => $dbForProject->getDocument('buckets', $bucketId)); + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isAPIKey = User::isApp(Authorization::getRoles()); $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); @@ -79,7 +79,7 @@ class Get extends Action if ($fileSecurity && !$valid) { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); } else { - $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); } if ($file->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php index 182cc7e172..b70ada75d3 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php @@ -11,7 +11,7 @@ class Get extends Action return 'getFileForPush'; } - // FILE PUSH - GET /v1/storage/buckets/:bucketId/files/:fileId/push + // FILE PUSH - GET /v1/storage/buckets/:bucketId/files/:fileId/push // Endpoint implementation from /app/controllers/api/storage.php lines 1487-1641 // Provides file access for push notifications with JWT validation } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php index bdd8f4b493..f9448f7d87 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php @@ -73,7 +73,7 @@ class XList extends Action Database $dbForProject, string $mode ) { - $bucket = Authorization::skip(fn() => $dbForProject->getDocument('buckets', $bucketId)); + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isAPIKey = User::isApp(Authorization::getRoles()); $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); @@ -115,7 +115,7 @@ class XList extends Action if ($fileSecurity && !$valid) { $cursorDocument = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); } else { - $cursorDocument = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + $cursorDocument = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); } if ($cursorDocument->isEmpty()) { @@ -132,8 +132,8 @@ class XList extends Action $files = $dbForProject->find('bucket_' . $bucket->getSequence(), $queries); $total = $includeTotal ? $dbForProject->count('bucket_' . $bucket->getSequence(), $filterQueries, APP_LIMIT_COUNT) : 0; } else { - $files = Authorization::skip(fn() => $dbForProject->find('bucket_' . $bucket->getSequence(), $queries)); - $total = $includeTotal ? Authorization::skip(fn() => $dbForProject->count('bucket_' . $bucket->getSequence(), $filterQueries, APP_LIMIT_COUNT)) : 0; + $files = Authorization::skip(fn () => $dbForProject->find('bucket_' . $bucket->getSequence(), $queries)); + $total = $includeTotal ? Authorization::skip(fn () => $dbForProject->count('bucket_' . $bucket->getSequence(), $filterQueries, APP_LIMIT_COUNT)) : 0; } } catch (NotFoundException) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php index 9f83479671..44f0192fb4 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php @@ -63,7 +63,7 @@ class Update extends Action ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('fileSecurity', false, new Boolean(true), 'Enables configuring permissions for individual file. A user needs one of file or bucket level permissions to access a file. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) - ->param('maximumFileSize', fn(array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn(array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) + ->param('maximumFileSize', fn (array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn (array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) ->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) diff --git a/src/Appwrite/Platform/Modules/Storage/Services/Http.php b/src/Appwrite/Platform/Modules/Storage/Services/Http.php index e60571eff0..95fe160f8b 100644 --- a/src/Appwrite/Platform/Modules/Storage/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Storage/Services/Http.php @@ -4,20 +4,20 @@ namespace Appwrite\Platform\Modules\Storage\Services; use Appwrite\Platform\Modules\Storage\Http\Buckets\Create as CreateBucket; use Appwrite\Platform\Modules\Storage\Http\Buckets\Delete as DeleteBucket; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Create as CreateFile; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Delete as DeleteFile; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Download\Get as GetFileDownload; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Get as GetFile; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Preview\Get as GetFilePreview; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Push\Get as GetFileForPush; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Update as UpdateFile; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\View\Get as GetFileView; +use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\XList as ListFiles; use Appwrite\Platform\Modules\Storage\Http\Buckets\Get as GetBucket; use Appwrite\Platform\Modules\Storage\Http\Buckets\Update as UpdateBucket; use Appwrite\Platform\Modules\Storage\Http\Buckets\XList as ListBuckets; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Create as CreateFile; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Delete as DeleteFile; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Get as GetFile; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Preview\Get as GetFilePreview; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Download\Get as GetFileDownload; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\View\Get as GetFileView; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Push\Get as GetFileForPush; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Update as UpdateFile; -use Appwrite\Platform\Modules\Storage\Http\Buckets\Files\XList as ListFiles; -use Appwrite\Platform\Modules\Storage\Http\Usage\XList as ListUsage; use Appwrite\Platform\Modules\Storage\Http\Usage\Get as GetBucketUsage; +use Appwrite\Platform\Modules\Storage\Http\Usage\XList as ListUsage; use Utopia\Platform\Service; class Http extends Service From f99cb20d05f7e63be7abc56d7553a78f18af24b6 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 29 Dec 2025 07:44:03 +0000 Subject: [PATCH 3/7] Initialize storage module and remove storage and fix remaining endpoints --- src/Appwrite/Platform/Appwrite.php | 2 + .../Storage/Http/Buckets/Files/Delete.php | 101 ++++++- .../Http/Buckets/Files/Download/Get.php | 202 ++++++++++++- .../Http/Buckets/Files/Preview/Get.php | 276 +++++++++++++++++- .../Storage/Http/Buckets/Files/Push/Get.php | 196 ++++++++++++- .../Storage/Http/Buckets/Files/Update.php | 104 ++++++- .../Storage/Http/Buckets/Files/View/Get.php | 214 +++++++++++++- .../Modules/Storage/Http/Usage/Get.php | 126 +++++++- .../Modules/Storage/Http/Usage/XList.php | 109 ++++++- 9 files changed, 1306 insertions(+), 24 deletions(-) diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 4aa135c4f1..a34c79308a 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -10,6 +10,7 @@ use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; +use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Tokens; use Utopia\Platform\Platform; @@ -26,5 +27,6 @@ class Appwrite extends Platform $this->addModule(new Console\Module()); $this->addModule(new Proxy\Module()); $this->addModule(new Tokens\Module()); + $this->addModule(new Storage\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index 09e019335a..a7ad0851d7 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -2,16 +2,111 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files; +use Appwrite\Event\Delete as DeleteEvent; +use Appwrite\Event\Event; +use Appwrite\Extend\Exception; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\ContentType; +use Appwrite\SDK\Method; +use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Response; +use Utopia\Database\Database; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; class Delete extends Action { + use HTTP; + public static function getName() { return 'deleteFile'; } - // FILE DELETE - DELETE /v1/storage/buckets/:bucketId/files/:fileId - // Endpoint implementation from /app/controllers/api/storage.php lines 1758-1864 - // Deletes file from storage device and database with proper cleanup + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId') + ->desc('Delete file') + ->groups(['api', 'storage']) + ->label('scope', 'files.write') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('event', 'buckets.[bucketId].files.[fileId].delete') + ->label('audits.event', 'file.delete') + ->label('audits.resource', 'file/{request.fileId}') + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'deleteFile', + description: '/docs/references/storage/delete-file.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('bucketId', '', new UID(), 'Bucket unique ID.') + ->param('fileId', '', new UID(), 'File ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + Response $response, + Database $dbForProject, + DeleteEvent $queueForDeletes, + Event $queueForEvents + ) { + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + // Validate delete permission + $validator = new Authorization(Database::PERMISSION_DELETE); + $validBucketDelete = $validator->isValid($bucket->getDelete()); + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + + if (!$validBucketDelete && !$fileSecurity) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + // Fetch file based on security + if ($fileSecurity && !$validBucketDelete) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('bucket_' . $bucket->getSequence(), $fileId)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove file from DB'); + } + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($file); + + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setParam('fileId', $file->getId()) + ->setPayload($response->output($file, Response::MODEL_FILE)); + + $response->noContent(); + } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php index 3efc003fe8..45e3b83375 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php @@ -2,16 +2,212 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Download; +use Appwrite\Extend\Exception; +use Appwrite\OpenSSL\OpenSSL; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\ContentType; +use Appwrite\SDK\Method; +use Appwrite\SDK\MethodType; +use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; +use Appwrite\Utopia\Response; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; +use Utopia\Storage\Compression\Algorithms\GZIP; +use Utopia\Storage\Compression\Algorithms\Zstd; +use Utopia\Storage\Compression\Compression; +use Utopia\Storage\Device; +use Utopia\Swoole\Request; +use Utopia\System\System; +use Utopia\Validator\Text; class Get extends Action { + use HTTP; + public static function getName() { return 'getFileDownload'; } - // FILE DOWNLOAD - GET /v1/storage/buckets/:bucketId/files/:fileId/download - // Endpoint implementation from /app/controllers/api/storage.php lines 1154-1314 - // Provides file download with range request support and proper headers + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId/download') + ->desc('Get file for download') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'getFileDownload', + description: '/docs/references/storage/get-file-download.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::ANY, + type: MethodType::LOCATION + )) + ->param('bucketId', '', new UID(), 'Storage bucket ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + // NOTE: this is only for the sdk generator and is not used in the action below and is utilised in `resources.php` for `resourceToken`. + ->param('token', '', new Text(512), 'File token for accessing this file.', true) + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('mode') + ->inject('resourceToken') + ->inject('deviceForFiles') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + ?string $token, + Request $request, + Response $response, + Database $dbForProject, + string $mode, + Document $resourceToken, + Device $deviceForFiles + ) { + /* @type Document $bucket */ + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid && !$isToken) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($fileSecurity && !$valid && !$isToken) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + /* @type Document $file */ + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + + if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + $size = $file->getAttribute('sizeOriginal', 0); + + $rangeHeader = $request->getHeader('range'); + if (!empty($rangeHeader)) { + $start = $request->getRangeStart(); + $end = $request->getRangeEnd(); + $unit = $request->getRangeUnit(); + + if ($end === null || $end - $start > APP_STORAGE_READ_BUFFER) { + $end = min(($start + MAX_OUTPUT_CHUNK_SIZE - 1), ($size - 1)); + } + + if ($unit !== 'bytes' || $start >= $end || $end >= $size) { + throw new Exception(Exception::STORAGE_INVALID_RANGE); + } + + $response + ->addHeader('Accept-Ranges', 'bytes') + ->addHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $size) + ->addHeader('Content-Length', $end - $start + 1) + ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); + } + + $response + ->setContentType($file->getAttribute('mimeType')) + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->addHeader('X-Peak', \memory_get_peak_usage()) + ->addHeader('Content-Disposition', 'attachment; filename="' . $file->getAttribute('name', '') . '"') + ; + + $source = ''; + if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt + $source = $deviceForFiles->read($path); + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + + switch ($file->getAttribute('algorithm', Compression::NONE)) { + case Compression::ZSTD: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new Zstd(); + $source = $compressor->decompress($source); + break; + case Compression::GZIP: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new GZIP(); + $source = $compressor->decompress($source); + break; + } + + if (!empty($source)) { + if (!empty($rangeHeader)) { + $response->send(substr($source, $start, ($end - $start + 1))); + return; + } + $response->send($source); + return; + } + + if (!empty($rangeHeader)) { + $response->send($deviceForFiles->read($path, $start, ($end - $start + 1))); + return; + } + + if ($size > APP_STORAGE_READ_BUFFER) { + for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { + $response->chunk( + $deviceForFiles->read( + $path, + ($i * MAX_OUTPUT_CHUNK_SIZE), + min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) + ), + (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size + ); + } + } else { + $response->send($deviceForFiles->read($path)); + } + } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 5f33f9f323..9c4e49d0bb 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -2,16 +2,286 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Preview; +use Appwrite\Extend\Exception; +use Appwrite\OpenSSL\OpenSSL; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\ContentType; +use Appwrite\SDK\Method; +use Appwrite\SDK\MethodType; +use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; +use Appwrite\Utopia\Response; +use Utopia\CLI\Console; +use Utopia\Config\Config; +use Utopia\Database\Database; +use Utopia\Database\DateTime; +use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\UID; +use Utopia\Image\Image; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; +use Utopia\Storage\Compression\Algorithms\GZIP; +use Utopia\Storage\Compression\Algorithms\Zstd; +use Utopia\Storage\Compression\Compression; +use Utopia\Storage\Device; +use Utopia\Swoole\Request; +use Utopia\System\System; +use Utopia\Validator\HexColor; +use Utopia\Validator\Range; +use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class Get extends Action { + use HTTP; + public static function getName() { return 'getFilePreview'; } - // FILE PREVIEW - GET /v1/storage/buckets/:bucketId/files/:fileId/preview - // Endpoint implementation from /app/controllers/api/storage.php lines 938-1153 - // Provides image preview generation with crop, transformation, and rendering capabilities + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId/preview') + ->desc('Get file preview') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('cache', true) + ->label('cache.resourceType', 'bucket/{request.bucketId}') + ->label('cache.resource', 'file/{request.fileId}') + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'getFilePreview', + description: '/docs/references/storage/get-file-preview.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_NONE + ) + ], + type: MethodType::LOCATION, + contentType: ContentType::IMAGE + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File 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::getGravityTypes()), 'Image crop gravity. Can be one of ' . implode(",", Image::getGravityTypes()), true) + ->param('quality', -1, new Range(-1, 100), 'Preview image quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', 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(-360, 360), 'Preview image rotation in degrees. Pass an integer between -360 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) + // NOTE: this is only for the sdk generator and is not used in the action below and is utilised in `resources.php` for `resourceToken`. + ->param('token', '', new Text(512), 'File token for accessing this file.', true) + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('resourceToken') + ->inject('deviceForFiles') + ->inject('deviceForLocal') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + int $width, + int $height, + string $gravity, + int $quality, + int $borderWidth, + string $borderColor, + int $borderRadius, + float $opacity, + int $rotation, + string $background, + string $output, + ?string $token, + Request $request, + Response $response, + Database $dbForProject, + Document $resourceToken, + Device $deviceForFiles, + Device $deviceForLocal, + Document $project + ) { + + if (!\extension_loaded('imagick')) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); + } + + /* @type Document $bucket */ + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + if (!$bucket->getAttribute('transformations', true) && !$isAPIKey && !$isPrivilegedUser) { + throw new Exception(Exception::STORAGE_BUCKET_TRANSFORMATIONS_DISABLED); + } + + $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid && !$isToken) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($fileSecurity && !$valid && !$isToken) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + /* @type Document $file */ + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + + if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $inputs = Config::getParam('storage-inputs'); + $outputs = Config::getParam('storage-outputs'); + $fileLogos = Config::getParam('storage-logos'); + + $path = $file->getAttribute('path'); + $type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION)); + $algorithm = $file->getAttribute('algorithm', Compression::NONE); + $cipher = $file->getAttribute('openSSLCipher'); + $mime = $file->getAttribute('mimeType'); + if (!\in_array($mime, $inputs) || $file->getAttribute('sizeActual') > (int) System::getEnv('_APP_STORAGE_PREVIEW_LIMIT', APP_STORAGE_READ_BUFFER)) { + if (!\in_array($mime, $inputs)) { + $path = (\array_key_exists($mime, $fileLogos)) ? $fileLogos[$mime] : $fileLogos['default']; + } else { + // it was an image but the file size exceeded the limit + $path = $fileLogos['default_image']; + } + + $algorithm = Compression::NONE; + $cipher = null; + $background = (empty($background)) ? 'eceff1' : $background; + $type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION)); + $deviceForFiles = $deviceForLocal; + } + + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + if (empty($output)) { + // when file extension is provided but it's not one of our + // supported outputs we fallback to `jpg` + if (!empty($type) && !array_key_exists($type, $outputs)) { + $type = 'jpg'; + } + + // when file extension is not provided and the mime type is not one of our supported outputs + // we fallback to `jpg` output format + $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type; + } + + $startTime = \microtime(true); + + $source = $deviceForFiles->read($path); + + $downloadTime = \microtime(true) - $startTime; + + if (!empty($cipher)) { // Decrypt + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + + $decryptionTime = \microtime(true) - $startTime - $downloadTime; + + switch ($algorithm) { + case Compression::ZSTD: + $compressor = new Zstd(); + $source = $compressor->decompress($source); + break; + case Compression::GZIP: + $compressor = new GZIP(); + $source = $compressor->decompress($source); + break; + } + + $decompressionTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime; + + try { + $image = new Image($source); + } catch (\Exception $e) { + throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage()); + } + + $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 + 360) % 360); + } + + $data = $image->output($output, $quality); + + $renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime; + + $totalTime = \microtime(true) - $startTime; + + Console::info("File preview rendered,project=" . $project->getId() . ",bucket=" . $bucketId . ",file=" . $file->getId() . ",uri=" . $request->getURI() . ",total=" . $totalTime . ",rendering=" . $renderingTime . ",decryption=" . $decryptionTime . ",decompression=" . $decompressionTime . ",download=" . $downloadTime); + + $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; + + //Do not update transformedAt if it's a console user + if (!User::isPrivileged(Authorization::getRoles())) { + $transformedAt = $file->getAttribute('transformedAt', ''); + if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { + $file->setAttribute('transformedAt', DateTime::now()); + Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), $file)); + } + } + + $response + ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType($contentType) + ->file($data); + + unset($image); + } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php index b70ada75d3..67372435b1 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php @@ -2,16 +2,206 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Push; +use Ahc\Jwt\JWT; +use Ahc\Jwt\JWTException; +use Appwrite\Extend\Exception; +use Appwrite\OpenSSL\OpenSSL; +use Appwrite\Utopia\Database\Documents\User; +use Appwrite\Utopia\Response; +use Utopia\Config\Config; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; +use Utopia\Storage\Compression\Algorithms\GZIP; +use Utopia\Storage\Compression\Algorithms\Zstd; +use Utopia\Storage\Compression\Compression; +use Utopia\Storage\Device; +use Utopia\Swoole\Request; +use Utopia\System\System; +use Utopia\Validator\Text; class Get extends Action { + use HTTP; + public static function getName() { return 'getFileForPush'; } - // FILE PUSH - GET /v1/storage/buckets/:bucketId/files/:fileId/push - // Endpoint implementation from /app/controllers/api/storage.php lines 1487-1641 - // Provides file access for push notifications with JWT validation + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId/push') + ->desc('Get file for push notification') + ->groups(['api', 'storage']) + ->label('scope', 'public') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + ->param('jwt', '', new Text(2048, 0), 'JSON Web Token to validate', true) + ->inject('response') + ->inject('request') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('project') + ->inject('mode') + ->inject('deviceForFiles') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + string $jwt, + Response $response, + Request $request, + Database $dbForProject, + Database $dbForPlatform, + Document $project, + string $mode, + Device $deviceForFiles + ) { + $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); + + try { + $decoded = $decoder->decode($jwt); + } catch (JWTException) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ( + $decoded['projectId'] !== $project->getId() || + $decoded['bucketId'] !== $bucketId || + $decoded['fileId'] !== $fileId + ) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + $isInternal = $decoded['internal'] ?? false; + $disposition = $decoded['disposition'] ?? 'inline'; + $dbForProject = $isInternal ? $dbForPlatform : $dbForProject; + + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $mimes = Config::getParam('storage-mimes'); + + $path = $file->getAttribute('path', ''); + + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + $contentType = 'text/plain'; + + if (\in_array($file->getAttribute('mimeType'), $mimes)) { + $contentType = $file->getAttribute('mimeType'); + } + + $size = $file->getAttribute('sizeOriginal', 0); + + $rangeHeader = $request->getHeader('range'); + if (!empty($rangeHeader)) { + $start = $request->getRangeStart(); + $end = $request->getRangeEnd(); + $unit = $request->getRangeUnit(); + + if ($end === null || $end - $start > APP_STORAGE_READ_BUFFER) { + $end = min(($start + APP_STORAGE_READ_BUFFER - 1), ($size - 1)); + } + + if ($unit != 'bytes' || $start >= $end || $end >= $size) { + throw new Exception(Exception::STORAGE_INVALID_RANGE); + } + + $response + ->addHeader('Accept-Ranges', 'bytes') + ->addHeader('Content-Range', "bytes $start-$end/$size") + ->addHeader('Content-Length', $end - $start + 1) + ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); + } + + $response + ->setContentType($contentType) + ->addHeader('Content-Security-Policy', 'script-src none;') + ->addHeader('X-Content-Type-Options', 'nosniff') + ->addHeader('Content-Disposition', $disposition . '; filename="' . $file->getAttribute('name', '') . '"') + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->addHeader('X-Peak', \memory_get_peak_usage()); + + $source = ''; + if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt + $source = $deviceForFiles->read($path); + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + + switch ($file->getAttribute('algorithm', Compression::NONE)) { + case Compression::ZSTD: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new Zstd(); + $source = $compressor->decompress($source); + break; + case Compression::GZIP: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new GZIP(); + $source = $compressor->decompress($source); + break; + } + + if (!empty($source)) { + if (!empty($rangeHeader)) { + $response->send(substr($source, $start, ($end - $start + 1))); + return; + } + $response->send($source); + return; + } + + if (!empty($rangeHeader)) { + $response->send($deviceForFiles->read($path, $start, ($end - $start + 1))); + return; + } + + $size = $deviceForFiles->getFileSize($path); + if ($size > APP_STORAGE_READ_BUFFER) { + for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { + $response->chunk( + $deviceForFiles->read( + $path, + ($i * MAX_OUTPUT_CHUNK_SIZE), + min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) + ), + (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size + ); + } + } else { + $response->send($deviceForFiles->read($path)); + } + } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 91d6783bc5..f961bab184 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -2,16 +2,114 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files; +use Appwrite\Event\Event; +use Appwrite\Extend\Exception; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\Method; +use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Response; +use Utopia\Database\Database; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\Permissions; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; +use Utopia\Validator\Nullable; +use Utopia\Validator\Text; class Update extends Action { + use HTTP; + public static function getName() { return 'updateFile'; } - // FILE UPDATE - PUT /v1/storage/buckets/:bucketId/files/:fileId - // Endpoint implementation from /app/controllers/api/storage.php lines 1642-1757 - // Updates file metadata like name and permissions + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId') + ->desc('Update file') + ->groups(['api', 'storage']) + ->label('scope', 'files.write') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('event', 'buckets.[bucketId].files.[fileId].update') + ->label('audits.event', 'file.update') + ->label('audits.resource', 'file/{response.$id}') + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'updateFile', + description: '/docs/references/storage/update-file.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_FILE, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Bucket unique ID.') + ->param('fileId', '', new UID(), 'File ID.') + ->param('name', null, new Text(128), 'File name.', true) + ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + ?string $name, + ?array $permissions, + Response $response, + Database $dbForProject, + Event $queueForEvents + ) { + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + + $bucketUpdateValidator = new Authorization(Database::PERMISSION_UPDATE); + $bucketUpdateValid = $bucketUpdateValidator->isValid($bucket->getUpdate()); + + if (!$bucketUpdateValid && !$fileSecurity) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + // Fetch file depending on fileSecurity & bucket permission + if ($fileSecurity && !$bucketUpdateValid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + // Aggregate provided permissions with existing ones if null + $permissions = Permission::aggregate($permissions ?? $file->getPermissions()); + + $name ??= $file->getAttribute('name'); + + $file = $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file + ->setAttribute('name', $name) + ->setAttribute('$permissions', $permissions)); + + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setParam('fileId', $file->getId()); + + $response->dynamic($file, Response::MODEL_FILE); + } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php index 2339efd93b..41ee95b165 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php @@ -2,16 +2,224 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files\View; +use Appwrite\Extend\Exception; +use Appwrite\OpenSSL\OpenSSL; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\ContentType; +use Appwrite\SDK\Method; +use Appwrite\SDK\MethodType; +use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; +use Appwrite\Utopia\Response; +use Utopia\Config\Config; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; +use Utopia\Storage\Compression\Algorithms\GZIP; +use Utopia\Storage\Compression\Algorithms\Zstd; +use Utopia\Storage\Compression\Compression; +use Utopia\Storage\Device; +use Utopia\Swoole\Request; +use Utopia\System\System; +use Utopia\Validator\Text; class Get extends Action { + use HTTP; + public static function getName() { return 'getFileView'; } - // FILE VIEW - GET /v1/storage/buckets/:bucketId/files/:fileId/view - // Endpoint implementation from /app/controllers/api/storage.php lines 1315-1486 - // Provides file view inline with content type enforcement and security headers + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/buckets/:bucketId/files/:fileId/view') + ->desc('Get file for view') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: 'files', + name: 'getFileView', + description: '/docs/references/storage/get-file-view.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::ANY, + type: MethodType::LOCATION + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + // NOTE: this is only for the sdk generator and is not used in the action below and is utilised in `resources.php` for `resourceToken`. + ->param('token', '', new Text(512), 'File token for accessing this file.', true) + ->inject('response') + ->inject('request') + ->inject('dbForProject') + ->inject('mode') + ->inject('resourceToken') + ->inject('deviceForFiles') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + ?string $token, + Response $response, + Request $request, + Database $dbForProject, + string $mode, + Document $resourceToken, + Device $deviceForFiles + ) { + /* @type Document $bucket */ + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid && !$isToken) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($fileSecurity && !$valid && !$isToken) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + /* @type Document $file */ + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + + if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $mimes = Config::getParam('storage-mimes'); + + $path = $file->getAttribute('path', ''); + + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + $contentType = 'text/plain'; + + if (\in_array($file->getAttribute('mimeType'), $mimes)) { + $contentType = $file->getAttribute('mimeType'); + } + + $size = $file->getAttribute('sizeOriginal', 0); + + $rangeHeader = $request->getHeader('range'); + if (!empty($rangeHeader)) { + $start = $request->getRangeStart(); + $end = $request->getRangeEnd(); + $unit = $request->getRangeUnit(); + + if ($end === null || $end - $start > APP_STORAGE_READ_BUFFER) { + $end = min(($start + APP_STORAGE_READ_BUFFER - 1), ($size - 1)); + } + + if ($unit != 'bytes' || $start >= $end || $end >= $size) { + throw new Exception(Exception::STORAGE_INVALID_RANGE); + } + + $response + ->addHeader('Accept-Ranges', 'bytes') + ->addHeader('Content-Range', "bytes $start-$end/$size") + ->addHeader('Content-Length', $end - $start + 1) + ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); + } + + $response + ->setContentType($contentType) + ->addHeader('Content-Security-Policy', 'script-src none;') + ->addHeader('X-Content-Type-Options', 'nosniff') + ->addHeader('Content-Disposition', 'inline; filename="' . $file->getAttribute('name', '') . '"') + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->addHeader('X-Peak', \memory_get_peak_usage()) + ; + + $source = ''; + if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt + $source = $deviceForFiles->read($path); + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + + switch ($file->getAttribute('algorithm', Compression::NONE)) { + case Compression::ZSTD: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new Zstd(); + $source = $compressor->decompress($source); + break; + case Compression::GZIP: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new GZIP(); + $source = $compressor->decompress($source); + break; + } + + if (!empty($source)) { + if (!empty($rangeHeader)) { + $response->send(substr($source, $start, ($end - $start + 1))); + return; + } + $response->send($source); + return; + } + + if (!empty($rangeHeader)) { + $response->send($deviceForFiles->read($path, $start, ($end - $start + 1))); + return; + } + + $size = $deviceForFiles->getFileSize($path); + if ($size > APP_STORAGE_READ_BUFFER) { + for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { + $response->chunk( + $deviceForFiles->read( + $path, + ($i * MAX_OUTPUT_CHUNK_SIZE), + min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) + ), + (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size + ); + } + } else { + $response->send($deviceForFiles->read($path)); + } + } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php index 496ac54582..b816e83f72 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php @@ -2,16 +2,136 @@ namespace Appwrite\Platform\Modules\Storage\Http\Usage; +use Appwrite\Extend\Exception; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\Method; +use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Response; +use Utopia\Config\Config; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; +use Utopia\Validator\WhiteList; class Get extends Action { + use HTTP; + public static function getName() { return 'getBucketUsage'; } - // BUCKET USAGE - GET /v1/storage/:bucketId/usage - // Endpoint implementation from /app/controllers/api/storage.php lines 1952-2053 - // Returns bucket-specific usage statistics + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/:bucketId/usage') + ->desc('Get bucket usage stats') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: null, + name: 'getBucketUsage', + description: '/docs/references/storage/get-bucket-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_USAGE_BUCKETS, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Bucket ID.') + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('project') + ->inject('dbForProject') + ->inject('getLogsDB') + ->callback($this->action(...)); + } + + public function action(string $bucketId, string $range, Response $response, Document $project, Database $dbForProject, callable $getLogsDB) + { + $dbForLogs = call_user_func($getLogsDB, $project); + $bucket = $dbForProject->getDocument('buckets', $bucketId); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $periods = Config::getParam('usage', []); + $stats = $usage = []; + $days = $periods[$range]; + $metrics = [ + str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES), + str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_STORAGE), + str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_IMAGES_TRANSFORMED), + ]; + + Authorization::skip(function () use ($dbForProject, $dbForLogs, $bucket, $days, $metrics, &$stats) { + foreach ($metrics as $metric) { + $db = ($metric === str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_IMAGES_TRANSFORMED)) + ? $dbForLogs + : $dbForProject; + + $result = $db->findOne('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', ['inf']) + ]); + + $stats[$metric]['total'] = $result['value'] ?? 0; + $limit = $days['limit']; + $period = $days['period']; + $results = $db->find('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', [$period]), + Query::limit($limit), + Query::orderDesc('time'), + ]); + $stats[$metric]['data'] = []; + foreach ($results as $result) { + $stats[$metric]['data'][$result->getAttribute('time')] = [ + 'value' => $result->getAttribute('value'), + ]; + } + } + }); + + + $format = match ($days['period']) { + '1h' => 'Y-m-d\\TH:00:00.000P', + '1d' => 'Y-m-d\\T00:00:00.000P', + }; + + foreach ($metrics as $metric) { + $usage[$metric]['total'] = $stats[$metric]['total']; + $usage[$metric]['data'] = []; + $leap = time() - ($days['limit'] * $days['factor']); + while ($leap < time()) { + $leap += $days['factor']; + $formatDate = date($format, $leap); + $usage[$metric]['data'][] = [ + 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, + 'date' => $formatDate, + ]; + } + } + + $response->dynamic(new Document([ + 'range' => $range, + 'filesTotal' => $usage[$metrics[0]]['total'], + 'filesStorageTotal' => $usage[$metrics[1]]['total'], + 'files' => $usage[$metrics[0]]['data'], + 'storage' => $usage[$metrics[1]]['data'], + 'imageTransformations' => $usage[$metrics[2]]['data'], + 'imageTransformationsTotal' => $usage[$metrics[2]]['total'], + ]), Response::MODEL_USAGE_BUCKETS); + } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php index 6d4bc921ef..d29fa7c1b4 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php @@ -2,16 +2,119 @@ namespace Appwrite\Platform\Modules\Storage\Http\Usage; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\Method; +use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Response; +use Utopia\Config\Config; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization; use Utopia\Platform\Action; +use Utopia\Platform\Scope\HTTP; +use Utopia\Validator\WhiteList; class XList extends Action { + use HTTP; + public static function getName() { return 'getUsage'; } - // STORAGE USAGE - GET /v1/storage/usage - // Endpoint implementation from /app/controllers/api/storage.php lines 1865-1951 - // Returns global storage usage statistics + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/storage/usage') + ->desc('Get storage usage stats') + ->groups(['api', 'storage']) + ->label('scope', 'files.read') + ->label('resourceType', RESOURCE_TYPE_BUCKETS) + ->label('sdk', new Method( + namespace: 'storage', + group: null, + name: 'getUsage', + description: '/docs/references/storage/get-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_USAGE_STORAGE, + ) + ] + )) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $range, Response $response, Database $dbForProject) + { + $periods = Config::getParam('usage', []); + $stats = $usage = []; + $days = $periods[$range]; + $metrics = [ + METRIC_BUCKETS, + METRIC_FILES, + METRIC_FILES_STORAGE, + ]; + + Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { + foreach ($metrics as $metric) { + $result = $dbForProject->findOne('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', ['inf']) + ]); + + $stats[$metric]['total'] = $result['value'] ?? 0; + $limit = $days['limit']; + $period = $days['period']; + $results = $dbForProject->find('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', [$period]), + Query::limit($limit), + Query::orderDesc('time'), + ]); + $stats[$metric]['data'] = []; + foreach ($results as $result) { + $stats[$metric]['data'][$result->getAttribute('time')] = [ + 'value' => $result->getAttribute('value'), + ]; + } + } + }); + + $format = match ($days['period']) { + '1h' => 'Y-m-d\\TH:00:00.000P', + '1d' => 'Y-m-d\\T00:00:00.000P', + }; + + foreach ($metrics as $metric) { + $usage[$metric]['total'] = $stats[$metric]['total']; + $usage[$metric]['data'] = []; + $leap = time() - ($days['limit'] * $days['factor']); + while ($leap < time()) { + $leap += $days['factor']; + $formatDate = date($format, $leap); + $usage[$metric]['data'][] = [ + 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, + 'date' => $formatDate, + ]; + } + } + + $response->dynamic(new Document([ + 'range' => $range, + 'bucketsTotal' => $usage[$metrics[0]]['total'], + 'filesTotal' => $usage[$metrics[1]]['total'], + 'filesStorageTotal' => $usage[$metrics[2]]['total'], + 'buckets' => $usage[$metrics[0]]['data'], + 'files' => $usage[$metrics[1]]['data'], + 'storage' => $usage[$metrics[2]]['data'], + ]), Response::MODEL_USAGE_STORAGE); + } } From 8cf12f685ab6622e3e175f016a7ab6042174f8c8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 29 Dec 2025 07:44:08 +0000 Subject: [PATCH 4/7] remove controller --- app/controllers/api/storage.php | 2052 ------------------------------- 1 file changed, 2052 deletions(-) delete mode 100644 app/controllers/api/storage.php diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php deleted file mode 100644 index ec4cc25ea3..0000000000 --- a/app/controllers/api/storage.php +++ /dev/null @@ -1,2052 +0,0 @@ -desc('Create bucket') - ->groups(['api', 'storage']) - ->label('scope', 'buckets.write') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('event', 'buckets.[bucketId].create') - ->label('audits.event', 'bucket.create') - ->label('audits.resource', 'bucket/{response.$id}') - ->label('sdk', new Method( - namespace: 'storage', - group: 'buckets', - name: 'createBucket', - description: '/docs/references/storage/create-bucket.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_BUCKET, - ) - ] - )) - ->param('bucketId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('name', '', new Text(128), 'Bucket name') - ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('fileSecurity', false, new Boolean(true), 'Enables configuring permissions for individual file. A user needs one of file or bucket level permissions to access a file. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) - ->param('maximumFileSize', fn (array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn (array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) - ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) - ->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), 'Are image transformations enabled?', 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, bool $transformations, Response $response, Database $dbForProject, Event $queueForEvents) { - - $bucketId = $bucketId === 'unique()' ? ID::unique() : $bucketId; - - // Map aggregate permissions into the multiple permissions they represent. - $permissions = Permission::aggregate($permissions) ?? []; - $compression ??= Compression::NONE; - $encryption ??= true; - try { - $files = (Config::getParam('collections', [])['buckets'] ?? [])['files'] ?? []; - if (empty($files)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Files collection is not configured.'); - } - - $attributes = []; - $indexes = []; - - foreach ($files['attributes'] as $attribute) { - $attributes[] = new Document([ - '$id' => $attribute['$id'], - 'type' => $attribute['type'], - 'size' => $attribute['size'], - 'required' => $attribute['required'], - 'signed' => $attribute['signed'], - 'array' => $attribute['array'], - 'filters' => $attribute['filters'], - 'default' => $attribute['default'] ?? null, - 'format' => $attribute['format'] ?? '' - ]); - } - - foreach ($files['indexes'] as $index) { - $indexes[] = new Document([ - '$id' => $index['$id'], - 'type' => $index['type'], - 'attributes' => $index['attributes'], - 'lengths' => $index['lengths'], - 'orders' => $index['orders'], - ]); - } - - $dbForProject->createDocument('buckets', new Document([ - '$id' => $bucketId, - '$collection' => 'buckets', - '$permissions' => $permissions, - 'name' => $name, - 'maximumFileSize' => $maximumFileSize, - 'allowedFileExtensions' => $allowedFileExtensions, - 'fileSecurity' => $fileSecurity, - 'enabled' => $enabled, - 'compression' => $compression, - 'encryption' => $encryption, - 'antivirus' => $antivirus, - 'transformations' => $transformations, - 'search' => implode(' ', [$bucketId, $name]), - ])); - - $bucket = $dbForProject->getDocument('buckets', $bucketId); - - $dbForProject->createCollection('bucket_' . $bucket->getSequence(), $attributes, $indexes, permissions: $permissions, documentSecurity: $fileSecurity); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_BUCKET_ALREADY_EXISTS); - } - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ; - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($bucket, Response::MODEL_BUCKET); - }); - -App::get('/v1/storage/buckets') - ->desc('List buckets') - ->groups(['api', 'storage']) - ->label('scope', 'buckets.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: 'buckets', - name: 'listBuckets', - description: '/docs/references/storage/list-buckets.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_BUCKET_LIST, - ) - ] - )) - ->param('queries', [], new Buckets(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Buckets::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $bucketId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('buckets', $bucketId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Bucket '{$bucketId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - try { - $buckets = $dbForProject->find('buckets', $queries); - $total = $includeTotal ? $dbForProject->count('buckets', $filterQueries, APP_LIMIT_COUNT) : 0; - } catch (OrderException $e) { - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - $response->dynamic(new Document([ - 'buckets' => $buckets, - 'total' => $total, - ]), Response::MODEL_BUCKET_LIST); - }); - -App::get('/v1/storage/buckets/:bucketId') - ->desc('Get bucket') - ->groups(['api', 'storage']) - ->label('scope', 'buckets.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: 'buckets', - name: 'getBucket', - description: '/docs/references/storage/get-bucket.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_BUCKET, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Bucket unique ID.') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $bucketId, Response $response, Database $dbForProject) { - - $bucket = $dbForProject->getDocument('buckets', $bucketId); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $response->dynamic($bucket, Response::MODEL_BUCKET); - }); - -App::put('/v1/storage/buckets/:bucketId') - ->desc('Update bucket') - ->groups(['api', 'storage']) - ->label('scope', 'buckets.write') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('event', 'buckets.[bucketId].update') - ->label('audits.event', 'bucket.update') - ->label('audits.resource', 'bucket/{response.$id}') - ->label('sdk', new Method( - namespace: 'storage', - group: 'buckets', - name: 'updateBucket', - description: '/docs/references/storage/update-bucket.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_BUCKET, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Bucket unique ID.') - ->param('name', null, new Text(128), 'Bucket name', false) - ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('fileSecurity', false, new Boolean(true), 'Enables configuring permissions for individual file. A user needs one of file or bucket level permissions to access a file. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) - ->param('maximumFileSize', fn (array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn (array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) - ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) - ->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), 'Are image transformations enabled?', 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, bool $transformations, Response $response, Database $dbForProject, Event $queueForEvents) { - $bucket = $dbForProject->getDocument('buckets', $bucketId); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $permissions ??= $bucket->getPermissions(); - $maximumFileSize ??= $bucket->getAttribute('maximumFileSize', (int) System::getEnv('_APP_STORAGE_LIMIT', 0)); - $allowedFileExtensions ??= $bucket->getAttribute('allowedFileExtensions', []); - $enabled ??= $bucket->getAttribute('enabled', true); - $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); - - $bucket = $dbForProject->updateDocument('buckets', $bucket->getId(), $bucket - ->setAttribute('name', $name) - ->setAttribute('$permissions', $permissions) - ->setAttribute('maximumFileSize', $maximumFileSize) - ->setAttribute('allowedFileExtensions', $allowedFileExtensions) - ->setAttribute('fileSecurity', $fileSecurity) - ->setAttribute('enabled', $enabled) - ->setAttribute('encryption', $encryption) - ->setAttribute('compression', $compression) - ->setAttribute('antivirus', $antivirus) - ->setAttribute('transformations', $transformations)); - - $dbForProject->updateCollection('bucket_' . $bucket->getSequence(), $permissions, $fileSecurity); - - $queueForEvents - ->setParam('bucketId', $bucket->getId()); - - $response->dynamic($bucket, Response::MODEL_BUCKET); - }); - -App::delete('/v1/storage/buckets/:bucketId') - ->desc('Delete bucket') - ->groups(['api', 'storage']) - ->label('scope', 'buckets.write') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('audits.event', 'bucket.delete') - ->label('event', 'buckets.[bucketId].delete') - ->label('audits.resource', 'bucket/{request.bucketId}') - ->label('sdk', new Method( - namespace: 'storage', - group: 'buckets', - name: 'deleteBucket', - description: '/docs/references/storage/delete-bucket.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('bucketId', '', new UID(), 'Bucket unique ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForDeletes') - ->inject('queueForEvents') - ->action(function (string $bucketId, Response $response, Database $dbForProject, Delete $queueForDeletes, Event $queueForEvents) { - $bucket = $dbForProject->getDocument('buckets', $bucketId); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - if (!$dbForProject->deleteDocument('buckets', $bucketId)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove bucket from DB'); - } - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($bucket); - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setPayload($response->output($bucket, Response::MODEL_BUCKET)) - ; - - $response->noContent(); - }); - -App::post('/v1/storage/buckets/:bucketId/files') - ->alias('/v1/storage/files') - ->desc('Create file') - ->groups(['api', 'storage']) - ->label('scope', 'files.write') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('audits.event', 'file.create') - ->label('event', 'buckets.[bucketId].files.[fileId].create') - ->label('audits.resource', 'file/{response.$id}') - ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId},chunkId:{chunkId}') - ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) - ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'createFile', - description: '/docs/references/storage/create-file.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_FILE, - ) - ], - type: MethodType::UPLOAD, - requestType: ContentType::MULTIPART - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new CustomId(), 'File ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('file', [], new File(), 'Binary file. Appwrite SDKs provide helpers to handle file input. [Learn about file input](https://appwrite.io/docs/products/storage/upload-download#input-file).', skipValidation: true) - ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE])), 'An array of permission strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->inject('user') - ->inject('queueForEvents') - ->inject('mode') - ->inject('deviceForFiles') - ->inject('deviceForLocal') - ->action(function (string $bucketId, string $fileId, mixed $file, ?array $permissions, Request $request, Response $response, Database $dbForProject, Document $user, Event $queueForEvents, string $mode, Device $deviceForFiles, Device $deviceForLocal) { - - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $validator = new Authorization(Database::PERMISSION_CREATE); - if (!$validator->isValid($bucket->getCreate())) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - $allowedPermissions = [ - Database::PERMISSION_READ, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ]; - - // Map aggregate permissions to into the set of individual permissions they represent. - $permissions = Permission::aggregate($permissions, $allowedPermissions); - - // Add permissions for current the user if none were provided. - if (\is_null($permissions)) { - $permissions = []; - if (!empty($user->getId()) && !$isPrivilegedUser) { - foreach ($allowedPermissions as $permission) { - $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); - } - } - } - - // Users can only manage their own roles, API keys and Admin users can manage any - $roles = Authorization::getRoles(); - if (!$isAPIKey && !$isPrivilegedUser) { - foreach (Database::PERMISSIONS as $type) { - foreach ($permissions as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != $type) { - continue; - } - $role = (new Role( - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); - } - } - } - } - - $maximumFileSize = $bucket->getAttribute('maximumFileSize', 0); - if ($maximumFileSize > (int) System::getEnv('_APP_STORAGE_LIMIT', 0)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Maximum bucket file size is larger than _APP_STORAGE_LIMIT'); - } - - - $file = $request->getFiles('file'); - - // GraphQL multipart spec adds files with index keys - if (empty($file)) { - $file = $request->getFiles(0); - } - - if (empty($file)) { - throw new Exception(Exception::STORAGE_FILE_EMPTY); - } - - // Make sure we handle a single file and multiple files the same way - $fileName = (\is_array($file['name']) && isset($file['name'][0])) ? $file['name'][0] : $file['name']; - $fileTmpName = (\is_array($file['tmp_name']) && isset($file['tmp_name'][0])) ? $file['tmp_name'][0] : $file['tmp_name']; - $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; - - $contentRange = $request->getHeader('content-range'); - $fileId = $fileId === 'unique()' ? ID::unique() : $fileId; - $chunk = 1; - $chunks = 1; - - if (!empty($contentRange)) { - $start = $request->getContentRangeStart(); - $end = $request->getContentRangeEnd(); - $fileSize = $request->getContentRangeSize(); - $fileId = $request->getHeader('x-appwrite-id', $fileId); - // TODO make `end >= $fileSize` in next breaking version - if (is_null($start) || is_null($end) || is_null($fileSize) || $end > $fileSize) { - throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); - } - - $idValidator = new UID(); - if (!$idValidator->isValid($fileId)) { - throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID); - } - - // TODO remove the condition that checks `$end === $fileSize` in next breaking version - if ($end === $fileSize - 1 || $end === $fileSize) { - //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to -1 notify it's last chunk - $chunks = $chunk = -1; - } else { - // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) - $chunks = (int) ceil($fileSize / ($end + 1 - $start)); - $chunk = (int) ($start / ($end + 1 - $start)) + 1; - } - } - - /** - * Validators - */ - // Check if file type is allowed - $allowedFileExtensions = $bucket->getAttribute('allowedFileExtensions', []); - $fileExt = new FileExt($allowedFileExtensions); - if (!empty($allowedFileExtensions) && !$fileExt->isValid($fileName)) { - throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, 'File extension not allowed'); - } - - // Check if file size is exceeding allowed limit - $fileSizeValidator = new FileSize($maximumFileSize); - if (!$fileSizeValidator->isValid($fileSize)) { - throw new Exception(Exception::STORAGE_INVALID_FILE_SIZE, 'File size not allowed'); - } - - $upload = new Upload(); - if (!$upload->isValid($fileTmpName)) { - throw new Exception(Exception::STORAGE_INVALID_FILE); - } - - // Save to storage - $fileSize ??= $deviceForLocal->getFileSize($fileTmpName); - $path = $deviceForFiles->getPath($fileId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); - $path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root - - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - - $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; - if (!$file->isEmpty()) { - $chunks = $file->getAttribute('chunksTotal', 1); - $uploaded = $file->getAttribute('chunksUploaded', 0); - $metadata = $file->getAttribute('metadata', []); - - if ($chunk === -1) { - $chunk = $chunks; - } - - if ($uploaded === $chunks) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } - } - - $chunksUploaded = $deviceForFiles->upload($fileTmpName, $path, $chunk, $chunks, $metadata); - - if (empty($chunksUploaded)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file'); - } - - if ($chunksUploaded === $chunks) { - if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) { - $antivirus = new Network( - System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), - (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) - ); - - if (!$antivirus->fileScan($path)) { - $deviceForFiles->delete($path); - throw new Exception(Exception::STORAGE_INVALID_FILE); - } - } - - $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption - $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption - $data = ''; - // Compression - $algorithm = $bucket->getAttribute('compression', Compression::NONE); - if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) { - $data = $deviceForFiles->read($path); - switch ($algorithm) { - case Compression::ZSTD: - $compressor = new Zstd(); - break; - case Compression::GZIP: - default: - $compressor = new GZIP(); - break; - } - $data = $compressor->compress($data); - } else { - // reset the algorithm to none as we do not compress the file - // if file size exceedes the APP_STORAGE_READ_BUFFER - // regardless the bucket compression algoorithm - $algorithm = Compression::NONE; - } - - if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { - if (empty($data)) { - $data = $deviceForFiles->read($path); - } - $key = System::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 (!empty($data)) { - if (!$deviceForFiles->write($path, $data, $mimeType)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file'); - } - } - - $sizeActual = $deviceForFiles->getFileSize($path); - - $openSSLVersion = null; - $openSSLCipher = null; - $openSSLTag = null; - $openSSLIV = null; - - if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { - $openSSLVersion = '1'; - $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM; - $openSSLTag = \bin2hex($tag); - $openSSLIV = \bin2hex($iv); - } - - if ($file->isEmpty()) { - $doc = new Document([ - '$id' => $fileId, - '$permissions' => $permissions, - 'bucketId' => $bucket->getId(), - 'bucketInternalId' => $bucket->getSequence(), - 'name' => $fileName, - 'path' => $path, - 'signature' => $fileHash, - 'mimeType' => $mimeType, - 'sizeOriginal' => $fileSize, - 'sizeActual' => $sizeActual, - 'algorithm' => $algorithm, - 'comment' => '', - 'chunksTotal' => $chunks, - 'chunksUploaded' => $chunksUploaded, - 'openSSLVersion' => $openSSLVersion, - 'openSSLCipher' => $openSSLCipher, - 'openSSLTag' => $openSSLTag, - 'openSSLIV' => $openSSLIV, - 'search' => implode(' ', [$fileId, $fileName]), - 'metadata' => $metadata, - ]); - - try { - $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } else { - $file = $file - ->setAttribute('$permissions', $permissions) - ->setAttribute('signature', $fileHash) - ->setAttribute('mimeType', $mimeType) - ->setAttribute('sizeActual', $sizeActual) - ->setAttribute('algorithm', $algorithm) - ->setAttribute('openSSLVersion', $openSSLVersion) - ->setAttribute('openSSLCipher', $openSSLCipher) - ->setAttribute('openSSLTag', $openSSLTag) - ->setAttribute('openSSLIV', $openSSLIV) - ->setAttribute('metadata', $metadata) - ->setAttribute('chunksUploaded', $chunksUploaded); - - /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission - * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update - */ - $validator = new Authorization(Database::PERMISSION_CREATE); - if (!$validator->isValid($bucket->getCreate())) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - $file = Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); - } - } else { - if ($file->isEmpty()) { - $doc = new Document([ - '$id' => ID::custom($fileId), - '$permissions' => $permissions, - 'bucketId' => $bucket->getId(), - 'bucketInternalId' => $bucket->getSequence(), - 'name' => $fileName, - 'path' => $path, - 'signature' => '', - 'mimeType' => '', - 'sizeOriginal' => $fileSize, - 'sizeActual' => 0, - 'algorithm' => '', - 'comment' => '', - 'chunksTotal' => $chunks, - 'chunksUploaded' => $chunksUploaded, - 'search' => implode(' ', [$fileId, $fileName]), - 'metadata' => $metadata, - ]); - - try { - $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } else { - $file = $file - ->setAttribute('chunksUploaded', $chunksUploaded) - ->setAttribute('metadata', $metadata); - - /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission - * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update - */ - $validator = new Authorization(Database::PERMISSION_CREATE); - if (!$validator->isValid($bucket->getCreate())) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - try { - $file = Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } - } - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setContext('bucket', $bucket); - - $metadata = null; // was causing leaks as it was passed by reference - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($file, Response::MODEL_FILE); - }); - -App::get('/v1/storage/buckets/:bucketId/files') - ->alias('/v1/storage/files') - ->desc('List files') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'listFiles', - description: '/docs/references/storage/list-files.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_FILE_LIST, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('queries', [], new Files(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Files::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->action(function (string $bucketId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject, string $mode) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - $queries = Query::parseQueries($queries); - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $fileId = $cursor->getValue(); - - if ($fileSecurity && !$valid) { - $cursorDocument = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - $cursorDocument = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - } - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "File '{$fileId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - try { - if ($fileSecurity && !$valid) { - $files = $dbForProject->find('bucket_' . $bucket->getSequence(), $queries); - $total = $includeTotal ? $dbForProject->count('bucket_' . $bucket->getSequence(), $filterQueries, APP_LIMIT_COUNT) : 0; - } else { - $files = Authorization::skip(fn () => $dbForProject->find('bucket_' . $bucket->getSequence(), $queries)); - $total = $includeTotal ? Authorization::skip(fn () => $dbForProject->count('bucket_' . $bucket->getSequence(), $filterQueries, APP_LIMIT_COUNT)) : 0; - } - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } catch (OrderException $e) { - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $response->dynamic(new Document([ - 'files' => $files, - 'total' => $total, - ]), Response::MODEL_FILE_LIST); - }); - -App::get('/v1/storage/buckets/:bucketId/files/:fileId') - ->alias('/v1/storage/files/:fileId') - ->desc('Get file') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'getFile', - description: '/docs/references/storage/get-file.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_FILE, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, string $mode) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $response->dynamic($file, Response::MODEL_FILE); - }); - -App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') - ->alias('/v1/storage/files/:fileId/preview') - ->desc('Get file preview') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('cache', true) - ->label('cache.resourceType', 'bucket/{request.bucketId}') - ->label('cache.resource', 'file/{request.fileId}') - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'getFilePreview', - description: '/docs/references/storage/get-file-preview.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_NONE - ) - ], - type: MethodType::LOCATION, - contentType: ContentType::IMAGE - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File 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::getGravityTypes()), 'Image crop gravity. Can be one of ' . implode(",", Image::getGravityTypes()), true) - ->param('quality', -1, new Range(-1, 100), 'Preview image quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', 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(-360, 360), 'Preview image rotation in degrees. Pass an integer between -360 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) - // NOTE: this is only for the sdk generator and is not used in the action below and is utilised in `resources.php` for `resourceToken`. - ->param('token', '', new Text(512), 'File token for accessing this file.', true) - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->inject('resourceToken') - ->inject('deviceForFiles') - ->inject('deviceForLocal') - ->inject('project') - ->action(function (string $bucketId, string $fileId, int $width, int $height, string $gravity, int $quality, int $borderWidth, string $borderColor, int $borderRadius, float $opacity, int $rotation, string $background, string $output, ?string $token, Request $request, Response $response, Database $dbForProject, Document $resourceToken, Device $deviceForFiles, Device $deviceForLocal, Document $project) { - - if (!\extension_loaded('imagick')) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); - } - - /* @type Document $bucket */ - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - if (!$bucket->getAttribute('transformations', true) && !$isAPIKey && !$isPrivilegedUser) { - throw new Exception(Exception::STORAGE_BUCKET_TRANSFORMATIONS_DISABLED); - } - - $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid && !$isToken) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid && !$isToken) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - /* @type Document $file */ - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - } - - if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $inputs = Config::getParam('storage-inputs'); - $outputs = Config::getParam('storage-outputs'); - $fileLogos = Config::getParam('storage-logos'); - - $path = $file->getAttribute('path'); - $type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION)); - $algorithm = $file->getAttribute('algorithm', Compression::NONE); - $cipher = $file->getAttribute('openSSLCipher'); - $mime = $file->getAttribute('mimeType'); - if (!\in_array($mime, $inputs) || $file->getAttribute('sizeActual') > (int) System::getEnv('_APP_STORAGE_PREVIEW_LIMIT', APP_STORAGE_READ_BUFFER)) { - if (!\in_array($mime, $inputs)) { - $path = (\array_key_exists($mime, $fileLogos)) ? $fileLogos[$mime] : $fileLogos['default']; - } else { - // it was an image but the file size exceeded the limit - $path = $fileLogos['default_image']; - } - - $algorithm = Compression::NONE; - $cipher = null; - $background = (empty($background)) ? 'eceff1' : $background; - $type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION)); - $deviceForFiles = $deviceForLocal; - } - - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - if (empty($output)) { - // when file extension is provided but it's not one of our - // supported outputs we fallback to `jpg` - if (!empty($type) && !array_key_exists($type, $outputs)) { - $type = 'jpg'; - } - - // when file extension is not provided and the mime type is not one of our supported outputs - // we fallback to `jpg` output format - $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type; - } - - $startTime = \microtime(true); - - $source = $deviceForFiles->read($path); - - $downloadTime = \microtime(true) - $startTime; - - if (!empty($cipher)) { // Decrypt - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - \hex2bin($file->getAttribute('openSSLIV')), - \hex2bin($file->getAttribute('openSSLTag')) - ); - } - - $decryptionTime = \microtime(true) - $startTime - $downloadTime; - - switch ($algorithm) { - case Compression::ZSTD: - $compressor = new Zstd(); - $source = $compressor->decompress($source); - break; - case Compression::GZIP: - $compressor = new GZIP(); - $source = $compressor->decompress($source); - break; - } - - $decompressionTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime; - - try { - $image = new Image($source); - } catch (ImagickException $e) { - throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage()); - } - - $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 + 360) % 360); - } - - $data = $image->output($output, $quality); - - $renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime; - - $totalTime = \microtime(true) - $startTime; - - Console::info("File preview rendered,project=" . $project->getId() . ",bucket=" . $bucketId . ",file=" . $file->getId() . ",uri=" . $request->getURI() . ",total=" . $totalTime . ",rendering=" . $renderingTime . ",decryption=" . $decryptionTime . ",decompression=" . $decompressionTime . ",download=" . $downloadTime); - - $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; - - //Do not update transformedAt if it's a console user - if (!User::isPrivileged(Authorization::getRoles())) { - $transformedAt = $file->getAttribute('transformedAt', ''); - if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { - $file->setAttribute('transformedAt', DateTime::now()); - Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), $file)); - } - } - - $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days - ->setContentType($contentType) - ->file($data); - - unset($image); - }); - -App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') - ->alias('/v1/storage/files/:fileId/download') - ->desc('Get file for download') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'getFileDownload', - description: '/docs/references/storage/get-file-download.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_NONE - ) - ], - type: MethodType::LOCATION, - contentType: ContentType::ANY, - )) - ->param('bucketId', '', new UID(), 'Storage bucket ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - // NOTE: this is only for the sdk generator and is not used in the action below and is utilised in `resources.php` for `resourceToken`. - ->param('token', '', new Text(512), 'File token for accessing this file.', true) - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->inject('resourceToken') - ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, ?string $token, Request $request, Response $response, Database $dbForProject, string $mode, Document $resourceToken, Device $deviceForFiles) { - /* @type Document $bucket */ - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid && !$isToken) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid && !$isToken) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - /* @type Document $file */ - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - } - - if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $path = $file->getAttribute('path', ''); - - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - $size = $file->getAttribute('sizeOriginal', 0); - - $rangeHeader = $request->getHeader('range'); - if (!empty($rangeHeader)) { - $start = $request->getRangeStart(); - $end = $request->getRangeEnd(); - $unit = $request->getRangeUnit(); - - if ($end === null || $end - $start > APP_STORAGE_READ_BUFFER) { - $end = min(($start + MAX_OUTPUT_CHUNK_SIZE - 1), ($size - 1)); - } - - if ($unit !== 'bytes' || $start >= $end || $end >= $size) { - throw new Exception(Exception::STORAGE_INVALID_RANGE); - } - - $response - ->addHeader('Accept-Ranges', 'bytes') - ->addHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $size) - ->addHeader('Content-Length', $end - $start + 1) - ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); - } - - $response - ->setContentType($file->getAttribute('mimeType')) - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->addHeader('X-Peak', \memory_get_peak_usage()) - ->addHeader('Content-Disposition', 'attachment; filename="' . $file->getAttribute('name', '') . '"') - ; - - $source = ''; - if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt - $source = $deviceForFiles->read($path); - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - \hex2bin($file->getAttribute('openSSLIV')), - \hex2bin($file->getAttribute('openSSLTag')) - ); - } - - switch ($file->getAttribute('algorithm', Compression::NONE)) { - case Compression::ZSTD: - if (empty($source)) { - $source = $deviceForFiles->read($path); - } - $compressor = new Zstd(); - $source = $compressor->decompress($source); - break; - case Compression::GZIP: - if (empty($source)) { - $source = $deviceForFiles->read($path); - } - $compressor = new GZIP(); - $source = $compressor->decompress($source); - break; - } - - if (!empty($source)) { - if (!empty($rangeHeader)) { - $response->send(substr($source, $start, ($end - $start + 1))); - return; - } - $response->send($source); - return; - } - - if (!empty($rangeHeader)) { - $response->send($deviceForFiles->read($path, $start, ($end - $start + 1))); - return; - } - - if ($size > APP_STORAGE_READ_BUFFER) { - for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { - $response->chunk( - $deviceForFiles->read( - $path, - ($i * MAX_OUTPUT_CHUNK_SIZE), - min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) - ), - (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size - ); - } - } else { - $response->send($deviceForFiles->read($path)); - } - }); - -App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') - ->alias('/v1/storage/files/:fileId/view') - ->desc('Get file for view') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'getFileView', - description: '/docs/references/storage/get-file-view.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_NONE, - ) - ], - type: MethodType::LOCATION, - contentType: ContentType::ANY, - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - // NOTE: this is only for the sdk generator and is not used in the action below and is utilised in `resources.php` for `resourceToken`. - ->param('token', '', new Text(512), 'File token for accessing this file.', true) - ->inject('response') - ->inject('request') - ->inject('dbForProject') - ->inject('mode') - ->inject('resourceToken') - ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, ?string $token, Response $response, Request $request, Database $dbForProject, string $mode, Document $resourceToken, Device $deviceForFiles) { - /* @type Document $bucket */ - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_READ); - $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid && !$isToken) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($fileSecurity && !$valid && !$isToken) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - /* @type Document $file */ - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - } - - if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $mimes = Config::getParam('storage-mimes'); - - $path = $file->getAttribute('path', ''); - - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - $contentType = 'text/plain'; - - if (\in_array($file->getAttribute('mimeType'), $mimes)) { - $contentType = $file->getAttribute('mimeType'); - } - - $size = $file->getAttribute('sizeOriginal', 0); - - $rangeHeader = $request->getHeader('range'); - if (!empty($rangeHeader)) { - $start = $request->getRangeStart(); - $end = $request->getRangeEnd(); - $unit = $request->getRangeUnit(); - - if ($end === null || $end - $start > APP_STORAGE_READ_BUFFER) { - $end = min(($start + APP_STORAGE_READ_BUFFER - 1), ($size - 1)); - } - - if ($unit != 'bytes' || $start >= $end || $end >= $size) { - throw new Exception(Exception::STORAGE_INVALID_RANGE); - } - - $response - ->addHeader('Accept-Ranges', 'bytes') - ->addHeader('Content-Range', "bytes $start-$end/$size") - ->addHeader('Content-Length', $end - $start + 1) - ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); - } - - $response - ->setContentType($contentType) - ->addHeader('Content-Security-Policy', 'script-src none;') - ->addHeader('X-Content-Type-Options', 'nosniff') - ->addHeader('Content-Disposition', 'inline; filename="' . $file->getAttribute('name', '') . '"') - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->addHeader('X-Peak', \memory_get_peak_usage()) - ; - - $source = ''; - if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt - $source = $deviceForFiles->read($path); - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - \hex2bin($file->getAttribute('openSSLIV')), - \hex2bin($file->getAttribute('openSSLTag')) - ); - } - - switch ($file->getAttribute('algorithm', Compression::NONE)) { - case Compression::ZSTD: - if (empty($source)) { - $source = $deviceForFiles->read($path); - } - $compressor = new Zstd(); - $source = $compressor->decompress($source); - break; - case Compression::GZIP: - if (empty($source)) { - $source = $deviceForFiles->read($path); - } - $compressor = new GZIP(); - $source = $compressor->decompress($source); - break; - } - - if (!empty($source)) { - if (!empty($rangeHeader)) { - $response->send(substr($source, $start, ($end - $start + 1))); - return; - } - $response->send($source); - return; - } - - if (!empty($rangeHeader)) { - $response->send($deviceForFiles->read($path, $start, ($end - $start + 1))); - return; - } - - $size = $deviceForFiles->getFileSize($path); - if ($size > APP_STORAGE_READ_BUFFER) { - for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { - $response->chunk( - $deviceForFiles->read( - $path, - ($i * MAX_OUTPUT_CHUNK_SIZE), - min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) - ), - (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size - ); - } - } else { - $response->send($deviceForFiles->read($path)); - } - }); - -App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') - ->desc('Get file for push notification') - ->groups(['api', 'storage']) - ->label('scope', 'public') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->param('jwt', '', new Text(2048, 0), 'JSON Web Token to validate', true) - ->inject('response') - ->inject('request') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('project') - ->inject('mode') - ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, Database $dbForPlatform, Document $project, string $mode, Device $deviceForFiles) { - $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); - - try { - $decoded = $decoder->decode($jwt); - } catch (JWTException) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ( - $decoded['projectId'] !== $project->getId() || - $decoded['bucketId'] !== $bucketId || - $decoded['fileId'] !== $fileId - ) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - $isInternal = $decoded['internal'] ?? false; - $disposition = $decoded['disposition'] ?? 'inline'; - $dbForProject = $isInternal ? $dbForPlatform : $dbForProject; - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $mimes = Config::getParam('storage-mimes'); - - $path = $file->getAttribute('path', ''); - - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - $contentType = 'text/plain'; - - if (\in_array($file->getAttribute('mimeType'), $mimes)) { - $contentType = $file->getAttribute('mimeType'); - } - - $size = $file->getAttribute('sizeOriginal', 0); - - $rangeHeader = $request->getHeader('range'); - if (!empty($rangeHeader)) { - $start = $request->getRangeStart(); - $end = $request->getRangeEnd(); - $unit = $request->getRangeUnit(); - - if ($end === null || $end - $start > APP_STORAGE_READ_BUFFER) { - $end = min(($start + APP_STORAGE_READ_BUFFER - 1), ($size - 1)); - } - - if ($unit != 'bytes' || $start >= $end || $end >= $size) { - throw new Exception(Exception::STORAGE_INVALID_RANGE); - } - - $response - ->addHeader('Accept-Ranges', 'bytes') - ->addHeader('Content-Range', "bytes $start-$end/$size") - ->addHeader('Content-Length', $end - $start + 1) - ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); - } - - $response - ->setContentType($contentType) - ->addHeader('Content-Security-Policy', 'script-src none;') - ->addHeader('X-Content-Type-Options', 'nosniff') - ->addHeader('Content-Disposition', $disposition . '; filename="' . $file->getAttribute('name', '') . '"') - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->addHeader('X-Peak', \memory_get_peak_usage()); - - $source = ''; - if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt - $source = $deviceForFiles->read($path); - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - \hex2bin($file->getAttribute('openSSLIV')), - \hex2bin($file->getAttribute('openSSLTag')) - ); - } - - switch ($file->getAttribute('algorithm', Compression::NONE)) { - case Compression::ZSTD: - if (empty($source)) { - $source = $deviceForFiles->read($path); - } - $compressor = new Zstd(); - $source = $compressor->decompress($source); - break; - case Compression::GZIP: - if (empty($source)) { - $source = $deviceForFiles->read($path); - } - $compressor = new GZIP(); - $source = $compressor->decompress($source); - break; - } - - if (!empty($source)) { - if (!empty($rangeHeader)) { - $response->send(substr($source, $start, ($end - $start + 1))); - return; - } - $response->send($source); - return; - } - - if (!empty($rangeHeader)) { - $response->send($deviceForFiles->read($path, $start, ($end - $start + 1))); - return; - } - - $size = $deviceForFiles->getFileSize($path); - if ($size > APP_STORAGE_READ_BUFFER) { - for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { - $response->chunk( - $deviceForFiles->read( - $path, - ($i * MAX_OUTPUT_CHUNK_SIZE), - min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) - ), - (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size - ); - } - } else { - $response->send($deviceForFiles->read($path)); - } - }); - -App::put('/v1/storage/buckets/:bucketId/files/:fileId') - ->alias('/v1/storage/files/:fileId') - ->desc('Update file') - ->groups(['api', 'storage']) - ->label('scope', 'files.write') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('event', 'buckets.[bucketId].files.[fileId].update') - ->label('audits.event', 'file.update') - ->label('audits.resource', 'file/{response.$id}') - ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') - ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) - ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'updateFile', - description: '/docs/references/storage/update-file.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_FILE, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File unique ID.') - ->param('name', null, new Nullable(new Text(255)), 'Name of the file', true) - ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE])), 'An array of permission string. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->inject('response') - ->inject('dbForProject') - ->inject('user') - ->inject('mode') - ->inject('queueForEvents') - ->action(function (string $bucketId, string $fileId, ?string $name, ?array $permissions, Response $response, Database $dbForProject, Document $user, string $mode, Event $queueForEvents) { - - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_UPDATE); - $valid = $validator->isValid($bucket->getUpdate()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - // Read permission should not be required for update - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - // Map aggregate permissions into the multiple permissions they represent. - $permissions = Permission::aggregate($permissions, [ - Database::PERMISSION_READ, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ]); - - // Users can only manage their own roles, API keys and Admin users can manage any - $roles = Authorization::getRoles(); - if (!User::isApp($roles) && !User::isPrivileged($roles) && !\is_null($permissions)) { - foreach (Database::PERMISSIONS as $type) { - foreach ($permissions as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != $type) { - continue; - } - $role = (new Role( - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); - } - } - } - } - - if (\is_null($permissions)) { - $permissions = $file->getPermissions() ?? []; - } - - $file->setAttribute('$permissions', $permissions); - - if (!is_null($name)) { - $file->setAttribute('name', $name); - } - - try { - if ($fileSecurity && !$valid) { - $file = $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file); - } else { - $file = Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); - } - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setContext('bucket', $bucket) - ; - - $response->dynamic($file, Response::MODEL_FILE); - }); - -App::delete('/v1/storage/buckets/:bucketId/files/:fileId') - ->desc('Delete file') - ->groups(['api', 'storage']) - ->label('scope', 'files.write') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('event', 'buckets.[bucketId].files.[fileId].delete') - ->label('audits.event', 'file.delete') - ->label('audits.resource', 'file/{request.fileId}') - ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') - ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) - ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) - ->label('sdk', new Method( - namespace: 'storage', - group: 'files', - name: 'deleteFile', - description: '/docs/references/storage/delete-file.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->inject('mode') - ->inject('deviceForFiles') - ->inject('queueForDeletes') - ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, Event $queueForEvents, string $mode, Device $deviceForFiles, Delete $queueForDeletes) { - $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - - $isAPIKey = User::isApp(Authorization::getRoles()); - $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); - - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(Database::PERMISSION_DELETE); - $valid = $validator->isValid($bucket->getDelete()); - if (!$fileSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - // Read permission should not be required for delete - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - // Make sure we don't delete the file before the document permission check occurs - if ($fileSecurity && !$valid && !$validator->isValid($file->getDelete())) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - $deviceDeleted = false; - if ($file->getAttribute('chunksTotal') !== $file->getAttribute('chunksUploaded')) { - $deviceDeleted = $deviceForFiles->abort( - $file->getAttribute('path'), - ($file->getAttribute('metadata', [])['uploadId'] ?? '') - ); - } else { - $deviceDeleted = $deviceForFiles->delete($file->getAttribute('path')); - } - - if ($deviceDeleted) { - $queueForDeletes - ->setType(DELETE_TYPE_CACHE_BY_RESOURCE) - ->setResourceType('bucket/' . $bucket->getId()) - ->setResource('file/' . $fileId) - ; - - try { - if ($fileSecurity && !$valid) { - $deleted = $dbForProject->deleteDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - $deleted = Authorization::skip(fn () => $dbForProject->deleteDocument('bucket_' . $bucket->getSequence(), $fileId)); - } - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - if (!$deleted) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove file from DB'); - } - } else { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to delete file from device'); - } - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setContext('bucket', $bucket) - ->setPayload($response->output($file, Response::MODEL_FILE)) - ; - - $response->noContent(); - }); - -/** Storage usage */ -App::get('/v1/storage/usage') - ->desc('Get storage usage stats') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: null, - name: 'getUsage', - description: '/docs/references/storage/get-usage.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_USAGE_STORAGE, - ) - ] - )) - ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $range, Response $response, Database $dbForProject) { - - $periods = Config::getParam('usage', []); - $stats = $usage = []; - $days = $periods[$range]; - $metrics = [ - METRIC_BUCKETS, - METRIC_FILES, - METRIC_FILES_STORAGE, - ]; - - $total = []; - Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats, &$total) { - foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', ['inf']) - ]); - - $stats[$metric]['total'] = $result['value'] ?? 0; - $limit = $days['limit']; - $period = $days['period']; - $results = $dbForProject->find('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', [$period]), - Query::limit($limit), - Query::orderDesc('time'), - ]); - $stats[$metric]['data'] = []; - foreach ($results as $result) { - $stats[$metric]['data'][$result->getAttribute('time')] = [ - 'value' => $result->getAttribute('value'), - ]; - } - } - }); - - $format = match ($days['period']) { - '1h' => 'Y-m-d\TH:00:00.000P', - '1d' => 'Y-m-d\T00:00:00.000P', - }; - - foreach ($metrics as $metric) { - $usage[$metric]['total'] = $stats[$metric]['total']; - $usage[$metric]['data'] = []; - $leap = time() - ($days['limit'] * $days['factor']); - while ($leap < time()) { - $leap += $days['factor']; - $formatDate = date($format, $leap); - $usage[$metric]['data'][] = [ - 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, - 'date' => $formatDate, - ]; - } - } - $response->dynamic(new Document([ - 'range' => $range, - 'bucketsTotal' => $usage[$metrics[0]]['total'], - 'filesTotal' => $usage[$metrics[1]]['total'], - 'filesStorageTotal' => $usage[$metrics[2]]['total'], - 'buckets' => $usage[$metrics[0]]['data'], - 'files' => $usage[$metrics[1]]['data'], - 'storage' => $usage[$metrics[2]]['data'], - ]), Response::MODEL_USAGE_STORAGE); - }); - -App::get('/v1/storage/:bucketId/usage') - ->desc('Get bucket usage stats') - ->groups(['api', 'storage']) - ->label('scope', 'files.read') - ->label('resourceType', RESOURCE_TYPE_BUCKETS) - ->label('sdk', new Method( - namespace: 'storage', - group: null, - name: 'getBucketUsage', - description: '/docs/references/storage/get-bucket-usage.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_USAGE_BUCKETS, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Bucket ID.') - ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) - ->inject('response') - ->inject('project') - ->inject('dbForProject') - ->inject('getLogsDB') - ->action(function (string $bucketId, string $range, Response $response, Document $project, Database $dbForProject, callable $getLogsDB) { - - $dbForLogs = call_user_func($getLogsDB, $project); - $bucket = $dbForProject->getDocument('buckets', $bucketId); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $periods = Config::getParam('usage', []); - $stats = $usage = []; - $days = $periods[$range]; - $metrics = [ - str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES), - str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_STORAGE), - str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_IMAGES_TRANSFORMED), - ]; - - Authorization::skip(function () use ($dbForProject, $dbForLogs, $bucket, $days, $metrics, &$stats) { - foreach ($metrics as $metric) { - $db = ($metric === str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_IMAGES_TRANSFORMED)) - ? $dbForLogs - : $dbForProject; - - $result = $db->findOne('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', ['inf']) - ]); - - $stats[$metric]['total'] = $result['value'] ?? 0; - $limit = $days['limit']; - $period = $days['period']; - $results = $db->find('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', [$period]), - Query::limit($limit), - Query::orderDesc('time'), - ]); - $stats[$metric]['data'] = []; - foreach ($results as $result) { - $stats[$metric]['data'][$result->getAttribute('time')] = [ - 'value' => $result->getAttribute('value'), - ]; - } - } - }); - - - $format = match ($days['period']) { - '1h' => 'Y-m-d\TH:00:00.000P', - '1d' => 'Y-m-d\T00:00:00.000P', - }; - - foreach ($metrics as $metric) { - $usage[$metric]['total'] = $stats[$metric]['total']; - $usage[$metric]['data'] = []; - $leap = time() - ($days['limit'] * $days['factor']); - while ($leap < time()) { - $leap += $days['factor']; - $formatDate = date($format, $leap); - $usage[$metric]['data'][] = [ - 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, - 'date' => $formatDate, - ]; - } - } - - $response->dynamic(new Document([ - 'range' => $range, - 'filesTotal' => $usage[$metrics[0]]['total'], - 'filesStorageTotal' => $usage[$metrics[1]]['total'], - 'files' => $usage[$metrics[0]]['data'], - 'storage' => $usage[$metrics[1]]['data'], - 'imageTransformations' => $usage[$metrics[2]]['data'], - 'imageTransformationsTotal' => $usage[$metrics[2]]['total'], - ]), Response::MODEL_USAGE_BUCKETS); - }); From 30373980f1c49d69881b92f13936423caf1f3a07 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 29 Dec 2025 07:56:46 +0000 Subject: [PATCH 5/7] fix update endpoint --- .../Storage/Http/Buckets/Files/Update.php | 79 ++++++++++++++----- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index f961bab184..be78cc358b 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -7,9 +7,12 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; @@ -73,42 +76,80 @@ class Update extends Action ) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - if ($bucket->isEmpty()) { + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } $fileSecurity = $bucket->getAttribute('fileSecurity', false); - - $bucketUpdateValidator = new Authorization(Database::PERMISSION_UPDATE); - $bucketUpdateValid = $bucketUpdateValidator->isValid($bucket->getUpdate()); - - if (!$bucketUpdateValid && !$fileSecurity) { + $validator = new Authorization(Database::PERMISSION_UPDATE); + $valid = $validator->isValid($bucket->getUpdate()); + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - // Fetch file depending on fileSecurity & bucket permission - if ($fileSecurity && !$bucketUpdateValid) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - } + // Read permission should not be required for update + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - // Aggregate provided permissions with existing ones if null - $permissions = Permission::aggregate($permissions ?? $file->getPermissions()); + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions, [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]); - $name ??= $file->getAttribute('name'); + // Users can only manage their own roles, API keys and Admin users can manage any + $roles = Authorization::getRoles(); + if (!User::isApp($roles) && !User::isPrivileged($roles) && !\is_null($permissions)) { + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!Authorization::isRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); + } + } + } + } - $file = $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file - ->setAttribute('name', $name) - ->setAttribute('$permissions', $permissions)); + if (\is_null($permissions)) { + $permissions = $file->getPermissions() ?? []; + } + + $file->setAttribute('$permissions', $permissions); + + if (!is_null($name)) { + $file->setAttribute('name', $name); + } + + try { + if ($fileSecurity && !$valid) { + $file = $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file); + } else { + $file = Authorization::skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); + } + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } $queueForEvents ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()); + ->setParam('fileId', $file->getId()) + ->setContext('bucket', $bucket) + ; $response->dynamic($file, Response::MODEL_FILE); } From 80bb9a1f335f59e3cb6cd1324e4e5c1760498ede Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 29 Dec 2025 08:41:38 +0000 Subject: [PATCH 6/7] fixe endpoints --- .../Storage/Http/Buckets/Files/Delete.php | 80 ++++++++++++++----- .../Storage/Http/Buckets/Files/Get.php | 4 +- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index a7ad0851d7..eccacaafd2 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -9,12 +9,15 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Storage\Device; class Delete extends Action { @@ -37,6 +40,9 @@ class Delete extends Action ->label('event', 'buckets.[bucketId].files.[fileId].delete') ->label('audits.event', 'file.delete') ->label('audits.resource', 'file/{request.fileId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) ->label('sdk', new Method( namespace: 'storage', group: 'files', @@ -51,12 +57,13 @@ class Delete extends Action ], contentType: ContentType::NONE )) - ->param('bucketId', '', new UID(), 'Bucket unique ID.') + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') ->param('fileId', '', new UID(), 'File ID.') ->inject('response') ->inject('dbForProject') - ->inject('queueForDeletes') ->inject('queueForEvents') + ->inject('deviceForFiles') + ->inject('queueForDeletes') ->callback($this->action(...)); } @@ -65,47 +72,78 @@ class Delete extends Action string $fileId, Response $response, Database $dbForProject, + Event $queueForEvents, + Device $deviceForFiles, DeleteEvent $queueForDeletes, - Event $queueForEvents ) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - if ($bucket->isEmpty()) { + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } - // Validate delete permission - $validator = new Authorization(Database::PERMISSION_DELETE); - $validBucketDelete = $validator->isValid($bucket->getDelete()); $fileSecurity = $bucket->getAttribute('fileSecurity', false); - - if (!$validBucketDelete && !$fileSecurity) { + $validator = new Authorization(Database::PERMISSION_DELETE); + $valid = $validator->isValid($bucket->getDelete()); + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - // Fetch file based on security - if ($fileSecurity && !$validBucketDelete) { - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - } else { - $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - } + // Read permission should not be required for delete + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - if (!$dbForProject->deleteDocument('bucket_' . $bucket->getSequence(), $fileId)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove file from DB'); + // Make sure we don't delete the file before the document permission check occurs + if ($fileSecurity && !$valid && !$validator->isValid($file->getDelete())) { + throw new Exception(Exception::USER_UNAUTHORIZED); } - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($file); + $deviceDeleted = false; + if ($file->getAttribute('chunksTotal') !== $file->getAttribute('chunksUploaded')) { + $deviceDeleted = $deviceForFiles->abort( + $file->getAttribute('path'), + ($file->getAttribute('metadata', [])['uploadId'] ?? '') + ); + } else { + $deviceDeleted = $deviceForFiles->delete($file->getAttribute('path')); + } + + if ($deviceDeleted) { + $queueForDeletes + ->setType(DELETE_TYPE_CACHE_BY_RESOURCE) + ->setResourceType('bucket/' . $bucket->getId()) + ->setResource('file/' . $fileId) + ; + + try { + if ($fileSecurity && !$valid) { + $deleted = $dbForProject->deleteDocument('bucket_' . $bucket->getSequence(), $fileId); + } else { + $deleted = Authorization::skip(fn () => $dbForProject->deleteDocument('bucket_' . $bucket->getSequence(), $fileId)); + } + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + if (!$deleted) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove file from DB'); + } + } else { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to delete file from device'); + } $queueForEvents ->setParam('bucketId', $bucket->getId()) ->setParam('fileId', $file->getId()) - ->setPayload($response->output($file, Response::MODEL_FILE)); + ->setContext('bucket', $bucket) + ->setPayload($response->output($file, Response::MODEL_FILE)) + ; $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php index e19fa8ae88..77f163e5fb 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php @@ -49,7 +49,6 @@ class Get extends Action ->param('fileId', '', new UID(), 'File ID.') ->inject('response') ->inject('dbForProject') - ->inject('mode') ->callback($this->action(...)); } @@ -58,7 +57,6 @@ class Get extends Action string $fileId, Response $response, Database $dbForProject, - string $mode ) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -70,7 +68,7 @@ class Get extends Action } $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization(\Utopia\Database\Database::PERMISSION_READ); + $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); From ca877fa71de4bd3601d23986a64a9420ec87137c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 29 Dec 2025 13:24:24 +0000 Subject: [PATCH 7/7] Catch query parse exceptions --- .../Platform/Modules/Storage/Http/Buckets/Files/XList.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php index f9448f7d87..e46fdb2a0a 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php @@ -89,7 +89,11 @@ class XList extends Action throw new Exception(Exception::USER_UNAUTHORIZED); } - $queries = Query::parseQueries($queries); + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } if (!empty($search)) { $queries[] = Query::search('search', $search);