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); + } }