diff --git a/.phive/phars.xml b/.phive/phars.xml index 17093fd..7ef9317 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,5 @@ - - + + diff --git a/CHANGELOG.md b/CHANGELOG.md index ab866e3..2a7732d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Changelog Releases for CakePHP 5.0 ------------------------ +* 2.1.0 + * Add pluggable storage drivers via `StorageAdapterInterface` and `AdapterFactory` + * Add Google Cloud Storage (`gcs`) adapter (requires `google/cloud-storage`) + * Add Cloudflare R2 (`r2`) adapter + * New config key `Uppy.driver` (env `UPPY_DRIVER`) selects the active backend + * Rename `Uppy.Props.deleteFileS3` to `deleteFileStorage`; the old key is still honored for backwards compatibility + * 2.0.3 * Update documentation diff --git a/Docs/Documentation/Configure.md b/Docs/Documentation/Configure.md index 1c4e1b2..ee8e8ba 100644 --- a/Docs/Documentation/Configure.md +++ b/Docs/Documentation/Configure.md @@ -6,7 +6,7 @@ To configure the plugin first copy the base configuration to your project $> cp vendor/cakedc/cakephp-uppy/config/uppy.php config/uppy.php ``` -Then run migration to create table *uppy_files* where save data for files uploaded +Then run migration to create table *uppy_files* where data for uploaded files is stored ``` $> bin/cake migrations migrate -p CakeDC/Uppy @@ -14,80 +14,135 @@ $> bin/cake migrations migrate -p CakeDC/Uppy Note: you can change the name of this table in the configuration file *config/uppy.php* +## Storage drivers + +The plugin supports three storage backends, selected via `Uppy.driver`: + +| Driver | Backend | Required env vars | Extra packages | +|---------|--------------------------|----------------------------------------------------------------|--------------------------------------| +| `s3` | Amazon S3 | `S3_REGION`, `S3_KEY`, `S3_SECRET`, `S3_BUCKET` | none (uses `aws/aws-sdk-php`) | +| `gcs` | Google Cloud Storage | `GCS_PROJECT_ID`, `GCS_KEY_FILE_PATH`, `GCS_BUCKET` | `composer require google/cloud-storage:^1.50` | +| `r2` | Cloudflare R2 | `R2_ACCOUNT_ID`, `R2_KEY`, `R2_SECRET`, `R2_BUCKET`, `R2_PUBLIC_DOMAIN`, `R2_ENDPOINT` | none (R2 is S3-compatible) | + +Set the driver via the `UPPY_DRIVER` env var (`s3` | `gcs` | `r2`), or override `Uppy.driver` directly in `config/uppy.php`. Default is `s3`. + +All drivers implement `CakeDC\Uppy\Storage\StorageAdapterInterface` and are instantiated by `CakeDC\Uppy\Storage\AdapterFactory::create()`. To add a custom driver, implement the interface and register a new case in the factory. + ## Configuration file *config/uppy.php* -By default, when debug is set to true the plugin will add a number of routes to your application `/uppy/files` in order to -upload and view the uploaded files. You should protect these endpoints in case your application requires -authentication to upload or manage files. Failing to do so would allow an authenticated user to *upload* files -to your application. +By default, when debug is set to true the plugin will add a number of routes to your application under `/uppy/files` for uploading and viewing files. You should protect these endpoints if your application requires authentication to upload or manage files. Failing to do so would allow any visitor to *upload* files to your application. -### Default uppy config file: +### Default uppy config file ```php [ + // Storage driver: 's3' | 'gcs' | 'r2' + 'driver' => env('UPPY_DRIVER', 's3'), + 'Props' => [ 'usersModel' => 'Users', - 'deleteFileS3' => true, + 'deleteFileStorage' => true, 'tableFiles' => 'uppy_files', ], + 'AcceptedContentTypes' => [ 'application/pdf', 'image/png', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ], + 'AcceptedExtensions' => [ - 'pdf', - 'png', + 'pdf', 'png', 'doc', 'docx', 'xls', 'ppt', 'pptx', ], - /* - * S3 configuration to manage files - */ + + // Amazon S3 — used when driver = 's3' 'S3' => [ - 'contants' => [ + 'constants' => [ 'lifeTimeGetObject' => '+20 minutes', 'lifeTimePutObject' => '+5 minutes', ], 'config' => [ 'version' => 'latest', - 'region' => filter_var(env('S3_REGION', null)), - 'endpoint' => filter_var(env('S3_END_POINT', null)), + 'region' => env('S3_REGION', null), 'credentials' => [ - 'key' => filter_var(env('S3_KEY', null)), - 'secret' => filter_var(env('S3_SECRET', null)), + 'key' => env('S3_KEY', null), + 'secret' => env('S3_SECRET', null), ], ], - 'bucket' => filter_var(env('S3_BUCKET')), + 'bucket' => env('S3_BUCKET', null), + ], + + // Google Cloud Storage — used when driver = 'gcs' + // Requires: composer require google/cloud-storage ^1.50 + 'GCS' => [ + 'projectId' => env('GCS_PROJECT_ID', null), + 'keyFilePath' => env('GCS_KEY_FILE_PATH', null), + 'bucket' => env('GCS_BUCKET', null), + 'lifeTimePutObject' => '+5 minutes', + 'lifeTimeGetObject' => '+20 minutes', + ], + + // Cloudflare R2 — used when driver = 'r2' + // R2 is S3-compatible; uses the AWS SDK with a custom endpoint. + 'R2' => [ + 'bucket' => env('R2_BUCKET', null), + 'publicDomain' => env('R2_PUBLIC_DOMAIN', null), + 'constants' => [ + 'lifeTimePutObject' => '+5 minutes', + 'lifeTimeGetObject' => '+20 minutes', + ], + 'config' => [ + 'version' => env('R2_VERSION', 'latest'), + 'region' => env('R2_REGION', 'auto'), + 'endpoint' => env('R2_ENDPOINT', null), + 'credentials' => [ + 'key' => env('R2_KEY', null), + 'secret' => env('R2_SECRET', null), + ], + 'use_path_style_endpoint' => true, + ], ], ], ]; ``` -- usersModel = is the alias name used in you app -- deleteFileS3 = if the record of the file in the database is deleted and it's marked true, the deletion is launched in the S3 deposit -- tableFiles = name of table used to store file data, default is `uppy_files` -- AcceptedContentTypes = list of content-type stored in S3 and saved in database -- AcceptedExtensions = list of file extensions stored in S3 and saved in database -- lifeTimeGetObject = life time generated link to access file in S3 -- lifeTimePutObject = life time generated link to post file in S3 -- region = configured region S3 -- endpoint = endpoint server to PUT/POST/GET S3 files -- key = S3 account key -- secret = S3 account secret -- bucket = bucket name - -Enpoints -------- - -- /uppy/files/sign = sign with credentials and return signed url to upload file in S3 directly in front -- /uppy/files/save = save register just uploaded in database with correct S3 path -- /uppy/files/delete = delete register in database, if Uppy.Props.deleteFileS3 is true remove from S3 -- /uppy/files/view = sign with credentials and return signed url to access file in S3 directly in front - -Sample -------- - -- /uppy/files = list of files in database and link to view/delete in S3 -- /uppy/files/add = example uppy upload file to configured S3 and save data relationed in database -- /uppy/files/drag = example uppy drag and upload multiple file, using Dashboard to configured S3 and save data relationed in database +### Props + +- `usersModel` — alias name used in your app for the users association +- `deleteFileStorage` — when `true`, deleting a file record also removes the object from the active storage backend. (Renamed from `deleteFileS3`; the old key still works as a fallback for backwards compatibility.) +- `tableFiles` — name of the table used to store file metadata, default `uppy_files` + +### Accepted types + +- `AcceptedContentTypes` — MIME types accepted for upload and persisted in the database +- `AcceptedExtensions` — file extensions accepted for upload and persisted in the database + +### Per-driver options + +- **S3 / R2** — `constants.lifeTimePutObject` / `constants.lifeTimeGetObject` control the signed URL lifetime for upload and read URLs respectively. `config` is passed directly to `Aws\S3\S3Client`. +- **GCS** — `lifeTimePutObject` / `lifeTimeGetObject` at the top level of the `GCS` block; `keyFilePath` is the path to a service-account JSON key file. +- **R2** — `publicDomain` is your custom domain or `pub-.r2.dev` URL; `endpoint` is the R2 S3-compatible endpoint (`https://.r2.cloudflarestorage.com`). + +In test environments, set `Uppy.S3.config.connection` or `Uppy.R2.config.connection` to `'dummy'` to bypass real network calls. + +## Endpoints + +- `/uppy/files/sign` — returns a signed URL for uploading a file directly from the browser to the configured storage +- `/uppy/files/save` — persists the database record for a file just uploaded, with the correct storage path +- `/uppy/files/delete` — deletes the database record and, when `Uppy.Props.deleteFileStorage` is `true`, also removes the object from storage +- `/uppy/files/view` — returns a signed URL for reading the file from the configured storage + +## Sample pages (debug only) + +These routes are only registered when `debug` is `true`: + +- `/uppy/files` — list of files in the database with view/delete links +- `/uppy/files/add` — example single-file upload to the configured storage +- `/uppy/files/drag` — example multi-file drag-and-drop upload using Uppy Dashboard diff --git a/Docs/Home.md b/Docs/Home.md index d4ae13a..fcd03d7 100644 --- a/Docs/Home.md +++ b/Docs/Home.md @@ -1,6 +1,6 @@ Home ==== -The **Uppy** Plugin integrate https://github.com/transloadit/uppy to upload files directly to a configured S3 usign a signature, and in the callback response save data related to files uploaded in database. +The **Uppy** Plugin integrates https://github.com/transloadit/uppy to upload files directly from the browser to a configured cloud storage backend using a presigned URL, and on callback persists the related file metadata in the database. Supported backends: Amazon S3, Google Cloud Storage, and Cloudflare R2. The plugin is thought as a base to extend and use your app specific controllers and views from. diff --git a/README.md b/README.md index 1d4e84f..c73b84e 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ CakeDC Uppy Plugin The **Uppy** plugin covers the following features: -* Upload directly files to configured S3 from frontside using a signature -* Save on configured table path to uploaded files on S3, mime_type, date, metadata and relation to user and specified related model in database indicated +* Upload files directly from the browser to a configured cloud storage backend using a presigned URL +* Pluggable storage drivers: **Amazon S3**, **Google Cloud Storage**, and **Cloudflare R2** (selectable via `Uppy.driver`) +* Persist file path, mime type, date, metadata and the relation to the user (or other related model) in the `uppy_files` table Requirements ------------ diff --git a/composer.json b/composer.json index 0bd0144..9832fb7 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,12 @@ "cakephp/cakephp-codesniffer": "^5.0", "phpunit/phpunit": "^10.1.0", "cakephp/migrations": "^4.0.0", - "markstory/asset_compress": "^5.0.0" + "markstory/asset_compress": "^5.0.0", + "google/cloud-storage": "^1.50", + "phpstan/phpstan": "^1.10" + }, + "suggest": { + "google/cloud-storage": "Required to use the GCS storage adapter (^1.50)" }, "autoload": { "psr-4": { @@ -40,15 +45,15 @@ ], "cs-check": "phpcs --colors --parallel=16 -p src/ tests/", "cs-fix": "phpcbf --colors --parallel=16 -p src/ tests/", - "phpstan": "tools/phpstan analyse", + "phpstan": "vendor/bin/phpstan analyse", "psalm": "tools/psalm --show-info=false", "stan": [ "@phpstan", "@psalm" ], - "phpstan-tests": "tools/phpstan analyze -c tests/phpstan.neon", - "phpstan-baseline": "tools/phpstan --generate-baseline", - "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", + "phpstan-tests": "vendor/bin/phpstan analyze -c tests/phpstan.neon", + "phpstan-baseline": "vendor/bin/phpstan --generate-baseline", + "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", "stan-setup": "phive install", "test": "phpunit" }, diff --git a/config/aliases.php b/config/aliases.php index 3b3e913..3cf0946 100644 --- a/config/aliases.php +++ b/config/aliases.php @@ -13,5 +13,9 @@ use Cake\Controller\Controller; use Cake\View\View; +if (!defined('CONFIG')) { + define('CONFIG', dirname(__DIR__) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR); +} + class_alias(Controller::class, 'App\Controller\AppController'); class_alias(View::class, '\App\View\AppView'); diff --git a/config/uppy.php b/config/uppy.php index c8356ab..7d935b8 100644 --- a/config/uppy.php +++ b/config/uppy.php @@ -9,17 +9,34 @@ * * @copyright Copyright 2023, Cake Development Corporation (https://www.cakedc.com) * @license MIT License (http://www.opensource.org/licenses/mit-license.php) + * + * Uppy plugin configuration template. + * Copy this file to your application's config/ directory and customise it. + * + * Required env vars per driver: + * s3: S3_REGION, S3_KEY, S3_SECRET, S3_BUCKET + * gcs: GCS_PROJECT_ID, GCS_KEY_FILE_PATH, GCS_BUCKET + * r2: R2_ACCOUNT_ID, R2_KEY, R2_SECRET, R2_BUCKET, R2_PUBLIC_DOMAIN */ return [ 'Uppy' => [ + /* + * Storage driver: 's3' | 'gcs' | 'r2' + * Set UPPY_DRIVER in your .env file or override here. + */ + 'driver' => env('UPPY_DRIVER', 's3'), + 'Props' => [ - // the Table Alias to identify the user who uploaded the file - 'usersAliasModel' => 'Users', - // the Table className to identify the user who uploaded the file 'usersModel' => 'Users', - 'deleteFileS3' => true, + /* + * When true and a file record is deleted, the object is also + * removed from storage. Renamed from deleteFileS3 to apply to + * all drivers. Falls back to deleteFileS3 for backwards compat. + */ + 'deleteFileStorage' => true, 'tableFiles' => 'uppy_files', ], + 'AcceptedContentTypes' => [ 'application/pdf', 'image/png', @@ -29,6 +46,7 @@ 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ], + 'AcceptedExtensions' => [ 'pdf', 'png', @@ -38,25 +56,62 @@ 'ppt', 'pptx', ], + /* - * S3 configuration to manage files - */ + * Amazon S3 — used when driver = 's3' + * Set connection = 'dummy' in test environments. + */ 'S3' => [ - 'contants' => [ + 'constants' => [ 'lifeTimeGetObject' => '+20 minutes', 'lifeTimePutObject' => '+5 minutes', ], 'config' => [ 'version' => 'latest', - 'connection' => 'real', //dummy - 'region' => filter_var(env('S3_REGION', null)), - 'endpoint' => filter_var(env('S3_END_POINT', null)), + 'region' => env('S3_REGION', null), + 'credentials' => [ + 'key' => env('S3_KEY', null), + 'secret' => env('S3_SECRET', null), + ], + ], + 'bucket' => env('S3_BUCKET', null), + ], + + /* + * Google Cloud Storage — used when driver = 'gcs' + * Requires: composer require google/cloud-storage ^1.50 + */ + 'GCS' => [ + 'projectId' => env('GCS_PROJECT_ID', null), + 'keyFilePath' => env('GCS_KEY_FILE_PATH', null), + 'bucket' => env('GCS_BUCKET', null), + 'lifeTimePutObject' => '+5 minutes', + 'lifeTimeGetObject' => '+20 minutes', + ], + + /* + * Cloudflare R2 — used when driver = 'r2' + * R2 is S3-compatible; uses AWS SDK with a custom endpoint. + * publicDomain: your custom domain or pub-.r2.dev URL. + * Set connection = 'dummy' in test environments. + */ + 'R2' => [ + 'bucket' => env('R2_BUCKET', null), + 'publicDomain' => env('R2_PUBLIC_DOMAIN', null), + 'constants' => [ + 'lifeTimePutObject' => '+5 minutes', + 'lifeTimeGetObject' => '+20 minutes', + ], + 'config' => [ + 'version' => env('R2_VERSION', 'latest'), + 'region' => env('R2_REGION', 'auto'), + 'endpoint' => env('R2_ENDPOINT', null), 'credentials' => [ - 'key' => filter_var(env('S3_KEY', null)), - 'secret' => filter_var(env('S3_SECRET', null)), + 'key' => env('R2_KEY', null), + 'secret' => env('R2_SECRET', null), ], + 'use_path_style_endpoint' => true, ], - 'bucket' => filter_var(env('S3_BUCKET')), ], ], ]; diff --git a/phive.xml b/phive.xml new file mode 100644 index 0000000..7ef9317 --- /dev/null +++ b/phive.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/phpcs.xml b/phpcs.xml index 015430f..b29774e 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,6 +1,8 @@ - + + src + tests diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 364905f..0fd0e7b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,2 +1,10 @@ parameters: ignoreErrors: + - + message: '#^Negated boolean expression is always false\.$#' + path: src/Storage/S3Adapter.php + count: 1 + - + message: '#^Negated boolean expression is always false\.$#' + path: src/Storage/R2Adapter.php + count: 1 diff --git a/phpstan.neon b/phpstan.neon index 0411426..dc64bec 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,8 +2,11 @@ includes: - phpstan-baseline.neon parameters: level: 8 - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false + ignoreErrors: + - + identifier: missingType.iterableValue + - + identifier: missingType.generics bootstrapFiles: - config/aliases.php paths: diff --git a/psalm.xml b/psalm.xml index b525688..6a8bebf 100644 --- a/psalm.xml +++ b/psalm.xml @@ -30,5 +30,9 @@ + + + + diff --git a/src/Controller/FilesController.php b/src/Controller/FilesController.php index 4c3a8a7..189e7d9 100644 --- a/src/Controller/FilesController.php +++ b/src/Controller/FilesController.php @@ -19,7 +19,10 @@ use Cake\ORM\Exception\MissingTableClassException; use Cake\Utility\Inflector; use Cake\Utility\Text; -use CakeDC\Uppy\Util\S3Trait; +use CakeDC\Uppy\Storage\AdapterFactory; +use CakeDC\Uppy\Storage\StorageAdapterInterface; +use UnexpectedValueException; +use function Cake\Core\h; use function Cake\I18n\__; /** @@ -29,7 +32,7 @@ */ class FilesController extends AppController { - use S3Trait; + private StorageAdapterInterface $storageAdapter; /** * Initialize method @@ -41,6 +44,7 @@ class FilesController extends AppController public function initialize(): void { parent::initialize(); + $this->storageAdapter = AdapterFactory::create(); if (!$this->components()->has('FormProtection')) { $this->loadComponent('FormProtection'); } @@ -65,7 +69,7 @@ public function view(string|int|null $id = null): ?Response /** @var \CakeDC\Uppy\Model\Entity\File $file */ $file = $this->Files->get($id); - $presignedUrl = $this->presignedUrl($file->path, $file->filename); + $presignedUrl = $this->storageAdapter->presignedUrl($file->path ?? ''); return $this->redirect($presignedUrl); } @@ -87,8 +91,8 @@ public function save(): void $files = []; $result = []; foreach ($items as $item) { - $tableAlias = $item['model'] ?? null; - if (!$tableAlias) { + $tableAlias = isset($item['model']) ? (string)$item['model'] : ''; + if ($tableAlias === '') { $result['error'] = true; $result['message'] = __('model is required'); $this->set('result', $result); @@ -106,8 +110,8 @@ public function save(): void return; } - $foreignKey = $item['foreign_key'] ?? null; - if (!$foreignKey) { + $foreignKey = isset($item['foreign_key']) ? (string)$item['foreign_key'] : ''; + if ($foreignKey === '') { $result['error'] = true; $result['message'] = __('foreign key is required'); $this->set('result', $result); @@ -136,7 +140,7 @@ public function save(): void $files[] = $file; } - if ($this->Files->saveMany($files)) { + if ($this->Files->saveMany($files) !== false) { $result['error'] = false; $result['message'] = __('The association has been be saved correctly'); } else { @@ -199,22 +203,24 @@ public function sign(): ?Response throw new PageOutOfBoundsException(__('contenType {0} is not valid', h($contentType))); } - $presignedRequest = $this->createPresignedRequest($filename, $contentType); + $presignedRequest = $this->storageAdapter->createPresignedRequest($filename, $contentType); + + $encoded = json_encode([ + 'error' => false, + 'code' => 200, + 'method' => $presignedRequest->getMethod(), + 'url' => (string)$presignedRequest->getUri(), + 'fields' => [], + // Also set the content-type header on the request, to make sure that it is the same as the one we used to generate the signature. + // Else, the browser picks a content-type as it sees fit. + 'headers' => [ + 'content-type' => $contentType, + ], + ]); return $this->getResponse() ->withHeader('content-type', 'application/json') - ->withStringBody(json_encode([ - 'error' => false, - 'code' => 200, - 'method' => $presignedRequest->getMethod(), - 'url' => (string)$presignedRequest->getUri(), - 'fields' => [], - // Also set the content-type header on the request, to make sure that it is the same as the one we used to generate the signature. - // Else, the browser picks a content-type as it sees fit. - 'headers' => [ - 'content-type' => $contentType, - ], - ]) ?: ''); + ->withStringBody($encoded !== false ? $encoded : ''); } /** @@ -223,7 +229,6 @@ public function sign(): ?Response public function add(): void { $this->getRequest()->allowMethod('get'); - $file = $this->Files->newEmptyEntity(); $this->set(compact('file')); diff --git a/src/Model/Table/FilesTable.php b/src/Model/Table/FilesTable.php index c8475b8..d4aaf27 100644 --- a/src/Model/Table/FilesTable.php +++ b/src/Model/Table/FilesTable.php @@ -24,7 +24,8 @@ use Cake\ORM\Table; use Cake\Validation\Validator; use CakeDC\Uppy\Model\Entity\File; -use CakeDC\Uppy\Util\S3Trait; +use CakeDC\Uppy\Storage\AdapterFactory; +use CakeDC\Uppy\Storage\StorageAdapterInterface; use function Cake\I18n\__; /** @@ -33,7 +34,7 @@ * @method \CakeDC\Uppy\Model\Entity\File newEmptyEntity() * @method \CakeDC\Uppy\Model\Entity\File newEntity(array $data, array $options = []) * @method \CakeDC\Uppy\Model\Entity\File[] newEntities(array $data, array $options = []) - * @method \CakeDC\Uppy\Model\Entity\File findOrCreate($search, ?callable $callback = null, $options = []) + * @method \CakeDC\Uppy\Model\Entity\File findOrCreate(array|callable $search, ?callable $callback = null, array $options = []) * @method \CakeDC\Uppy\Model\Entity\File patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) * @method \CakeDC\Uppy\Model\Entity\File[] patchEntities(iterable $entities, array $data, array $options = []) * @method \CakeDC\Uppy\Model\Entity\File|false save(\Cake\Datasource\EntityInterface $entity, $options = []) @@ -42,7 +43,19 @@ */ class FilesTable extends Table { - use S3Trait; + private ?StorageAdapterInterface $storageAdapter = null; + + /** + * @return \CakeDC\Uppy\Storage\StorageAdapterInterface + */ + public function getStorageAdapter(): StorageAdapterInterface + { + if ($this->storageAdapter === null) { + $this->storageAdapter = AdapterFactory::create(); + } + + return $this->storageAdapter; + } /** * Initialize method @@ -149,16 +162,16 @@ public function buildRules(RulesChecker $rules): RulesChecker $rules->add( $rules->existsIn( 'user_id', - Configure::readOrFail('Uppy.Props.usersAliasModel') + Configure::readOrFail('Uppy.Props.usersAliasModel'), ), - ['errorField' => 'user_id'] + ['errorField' => 'user_id'], ); return $rules; } /** - * If it's configured prop deleteFileS3 delete file in S3 repository + * If it's configured prop deleteFileStorage delete file from storage * * @param \Cake\Event\EventInterface $event The beforeSave event that was fired * @param \CakeDC\Uppy\Model\Entity\File $entity The entity that is going to be saved @@ -167,8 +180,12 @@ public function buildRules(RulesChecker $rules): RulesChecker */ public function afterDelete(EventInterface $event, File $entity, ArrayObject $options): void { - if (Configure::read('Uppy.Props.deleteFileS3', false)) { - $this->deleteObject($entity->path, $entity->filename); + $shouldDelete = Configure::read( + 'Uppy.Props.deleteFileStorage', + Configure::read('Uppy.Props.deleteFileS3', false), + ); + if ($shouldDelete) { + $this->getStorageAdapter()->deleteObject($entity->path ?? ''); } } @@ -187,22 +204,22 @@ public function findDatatable( int|string $patient_id, array $q = [], ?string $from_date = null, - ?string $to_date = null + ?string $to_date = null, ): SelectQuery { - if ($q['value'] ?? false) { - $query->where(fn (QueryExpression $exp): QueryExpression => $exp + if (isset($q['value']) && (string)$q['value'] !== '') { + $query->where(fn(QueryExpression $exp): QueryExpression => $exp ->like($this->aliasField('filename'), "%{$q['value']}%")); } - $query->where(fn (QueryExpression $exp): QueryExpression => $exp + $query->where(fn(QueryExpression $exp): QueryExpression => $exp ->eq($this->aliasField('user_id'), $patient_id)); - if ($from_date && $to_date) { - $query->where(fn (QueryExpression $exp): QueryExpression => $exp->between( + if ($from_date !== null && $to_date !== null) { + $query->where(fn(QueryExpression $exp): QueryExpression => $exp->between( $this->aliasField('created'), DateTime::parse($from_date)->startOfDay(), DateTime::parse($to_date)->endOfDay(), - 'datetime' + 'datetime', )); } @@ -215,12 +232,12 @@ public function findDatatable( 'path', 'created', ]) - ->formatResults(fn (CollectionInterface $results): CollectionInterface => $results + ->formatResults(fn(CollectionInterface $results): CollectionInterface => $results ->map(function (File $file): array { $row = []; $row['filename'] = $file->filename; $row['extension'] = $file->extension; - $row['signedUrl'] = $this->presignedUrl($file->path, $file->filename); + $row['signedUrl'] = $this->getStorageAdapter()->presignedUrl($file->path ?? ''); $row['filesize'] = Number::toReadableSize($file->filesize ?? 0); $row['created'] = $file->created?->i18nFormat('yyyy-MM-dd'); $row['id'] = $file->id; diff --git a/src/Storage/AdapterFactory.php b/src/Storage/AdapterFactory.php new file mode 100644 index 0000000..0e4572b --- /dev/null +++ b/src/Storage/AdapterFactory.php @@ -0,0 +1,34 @@ + new S3Adapter(), + 'gcs' => new GcsAdapter(), + 'r2' => new R2Adapter(), + default => throw new InvalidArgumentException("Unknown Uppy driver: {$driver}"), + }; + } +} diff --git a/src/Storage/GcsAdapter.php b/src/Storage/GcsAdapter.php new file mode 100644 index 0000000..ea66dc7 --- /dev/null +++ b/src/Storage/GcsAdapter.php @@ -0,0 +1,109 @@ +client ??= new StorageClient([ + 'projectId' => Configure::readOrFail('Uppy.GCS.projectId'), + 'keyFilePath' => Configure::readOrFail('Uppy.GCS.keyFilePath'), + ]); + } + + /** + * @return string + */ + private function bucket(): string + { + return (string)Configure::readOrFail('Uppy.GCS.bucket'); + } + + /** + * @param string $path + * @param string $contentType + * @return \Psr\Http\Message\RequestInterface + * @throws \Exception + */ + public function createPresignedRequest(string $path, string $contentType): RequestInterface + { + $expires = new DateTime(Configure::readOrFail('Uppy.GCS.lifeTimePutObject')); + $signedUrl = $this->getClient() + ->bucket($this->bucket()) + ->object($path) + ->signedUrl($expires, [ + 'method' => 'PUT', + 'contentType' => $contentType, + 'version' => 'v4', + ]); + + return (new Request($signedUrl, 'PUT'))->withHeader('Content-Type', $contentType); + } + + /** + * @param string $path + * @param int|null $ttlSeconds + * @return string + */ + public function presignedUrl(string $path, ?int $ttlSeconds = null): string + { + $ttlString = $ttlSeconds !== null + ? "+{$ttlSeconds} seconds" + : Configure::read('Uppy.GCS.lifeTimeGetObject', '+1 hour'); + $expires = new DateTime($ttlString); + + return $this->getClient() + ->bucket($this->bucket()) + ->object($path) + ->signedUrl($expires, [ + 'method' => 'GET', + 'version' => 'v4', + ]); + } + + /** + * @param string $path + * @return bool + */ + public function deleteObject(string $path): bool + { + try { + $this->getClient()->bucket($this->bucket())->object($path)->delete(); + + return true; + } catch (NotFoundException) { + return true; // already gone — idempotent + } catch (GoogleException) { + return false; + } + } +} diff --git a/src/Storage/R2Adapter.php b/src/Storage/R2Adapter.php new file mode 100644 index 0000000..f80b75c --- /dev/null +++ b/src/Storage/R2Adapter.php @@ -0,0 +1,117 @@ +client ??= new S3Client(Configure::readOrFail('Uppy.R2.config')); + } + + /** + * @return string + */ + private function bucket(): string + { + return (string)Configure::readOrFail('Uppy.R2.bucket'); + } + + /** + * @param string $path + * @param string $contentType + * @return \Psr\Http\Message\RequestInterface + */ + public function createPresignedRequest(string $path, string $contentType): RequestInterface + { + if (Configure::read('Uppy.R2.config.connection') === 'dummy') { + return (new Request())->withUri(new Uri('https://example.com')); + } + + $client = $this->getClient(); + + $command = $client->getCommand('PutObject', [ + 'Bucket' => $this->bucket(), + 'Key' => $path, + 'ContentType' => $contentType, + ]); + + return $client->createPresignedRequest( + $command, + Configure::readOrFail('Uppy.R2.constants.lifeTimePutObject'), + ); + } + + /** + * @param string $path + * @param int|null $ttlSeconds + * @return string + */ + public function presignedUrl(string $path, ?int $ttlSeconds = null): string + { + if (Configure::read('Uppy.R2.config.connection') === 'dummy') { + return 'https://example.com'; + } + + $client = $this->getClient(); + + $ttl = $ttlSeconds !== null + ? "+{$ttlSeconds} seconds" + : Configure::read('Uppy.R2.constants.lifeTimeGetObject', '+1 hour'); + + $cmd = $client->getCommand('GetObject', [ + 'Bucket' => $this->bucket(), + 'Key' => $path, + ]); + + $request = $client->createPresignedRequest($cmd, $ttl); + + return (string)$request->getUri(); + } + + /** + * @param string $path + * @return bool + */ + public function deleteObject(string $path): bool + { + if (Configure::read('Uppy.R2.config.connection') === 'dummy') { + return true; + } + + $client = $this->getClient(); + if (!$client->doesObjectExist($this->bucket(), $path)) { + return true; + } + + $client->deleteObject(['Bucket' => $this->bucket(), 'Key' => $path]); + + return !$client->doesObjectExist($this->bucket(), $path); + } +} diff --git a/src/Storage/S3Adapter.php b/src/Storage/S3Adapter.php new file mode 100644 index 0000000..24b5550 --- /dev/null +++ b/src/Storage/S3Adapter.php @@ -0,0 +1,113 @@ +client ??= new S3Client(Configure::readOrFail('Uppy.S3.config')); + } + + /** + * @return string + */ + private function bucket(): string + { + return (string)Configure::readOrFail('Uppy.S3.bucket'); + } + + /** + * @param string $path + * @param string $contentType + * @return \Psr\Http\Message\RequestInterface + */ + public function createPresignedRequest(string $path, string $contentType): RequestInterface + { + if (Configure::read('Uppy.S3.config.connection') === 'dummy') { + return (new Request())->withUri(new Uri('https://example.com')); + } + + $command = $this->getClient()->getCommand('PutObject', [ + 'Bucket' => $this->bucket(), + 'Key' => $path, + 'ContentType' => $contentType, + 'Body' => '', + ]); + + return $this->getClient()->createPresignedRequest( + $command, + Configure::readOrFail('Uppy.S3.constants.lifeTimePutObject'), + ); + } + + /** + * @param string $path + * @param int|null $ttlSeconds + * @return string + */ + public function presignedUrl(string $path, ?int $ttlSeconds = null): string + { + if (Configure::read('Uppy.S3.config.connection') === 'dummy') { + return 'https://example.com'; + } + + $ttl = $ttlSeconds !== null + ? "+{$ttlSeconds} seconds" + : Configure::read('Uppy.S3.constants.lifeTimeGetObject', '+1 hour'); + + $cmd = $this->getClient()->getCommand('GetObject', [ + 'Bucket' => $this->bucket(), + 'Key' => $path, + ]); + $request = $this->getClient()->createPresignedRequest($cmd, $ttl); + + return (string)$request->getUri(); + } + + /** + * @param string $path + * @return bool + */ + public function deleteObject(string $path): bool + { + if (Configure::read('Uppy.S3.config.connection') === 'dummy') { + return true; + } + + $client = $this->getClient(); + if (!$client->doesObjectExist($this->bucket(), $path)) { + return true; + } + + $client->deleteObject(['Bucket' => $this->bucket(), 'Key' => $path]); + + return !$client->doesObjectExist($this->bucket(), $path); + } +} diff --git a/src/Storage/StorageAdapterInterface.php b/src/Storage/StorageAdapterInterface.php new file mode 100644 index 0000000..831f523 --- /dev/null +++ b/src/Storage/StorageAdapterInterface.php @@ -0,0 +1,44 @@ +getS3Client(); $exist = $s3Client->doesObjectExist(Configure::readOrFail('Uppy.S3.bucket'), $path); if ($exist) { $s3Client->deleteObject([ @@ -54,8 +77,6 @@ protected function deleteObject(?string $path, ?string $name): bool } /** - * Generate a signed GET uRI to acces files in S3, note CORS must be configured for the domain - * * @see /config/cors.xml * @param string|null $path string used as path in S3 * @param string|null $name string filename @@ -67,19 +88,17 @@ protected function presignedUrl(?string $path, ?string $name): string return 'https://example.com'; } - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); + $s3Client = $this->getS3Client(); $cmd = $s3Client->getCommand('GetObject', [ 'Bucket' => Configure::readOrFail('Uppy.S3.bucket'), 'Key' => $path, ]); - $request = $s3Client->createPresignedRequest($cmd, Configure::read('Uppy.S3.contants.lifeTimeGetObject')); + $request = $s3Client->createPresignedRequest($cmd, $this->getS3Lifetime('lifeTimeGetObject')); return (string)$request->getUri(); } /** - * Generate a presigned PUT request to send files to S3, note CORS must be configured for the domain - * * @see /config/cors.xml * @param string $path string used as path in S3 * @param string $contentType string contenttype @@ -90,22 +109,20 @@ protected function createPresignedRequest(string $path, string $contentType): Re if (Configure::read('Uppy.S3.config.connection') === 'dummy') { return (new Request()) ->withUri(new Uri('https://example.com')); - } else { - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); - $command = $s3Client->getCommand('putObject', [ - 'Bucket' => Configure::readOrFail('Uppy.S3.bucket'), - 'Key' => $path, - 'ContentType' => $contentType, - 'Body' => '', - ]); - - return $s3Client->createPresignedRequest($command, Configure::readOrFail('Uppy.S3.contants.lifeTimePutObject')); } + + $s3Client = $this->getS3Client(); + $command = $s3Client->getCommand('putObject', [ + 'Bucket' => Configure::readOrFail('Uppy.S3.bucket'), + 'Key' => $path, + 'ContentType' => $contentType, + 'Body' => '', + ]); + + return $s3Client->createPresignedRequest($command, $this->getS3Lifetime('lifeTimePutObject')); } /** - * Upload source dir to target path using transfer options - * * @see /config/cors.xml * @param string $source string used as filesystem source dir * @param string $target string used as path in S3 @@ -113,7 +130,7 @@ protected function createPresignedRequest(string $path, string $contentType): Re */ public function uploadDir(string $source, string $target): void { - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); + $s3Client = $this->getS3Client(); $dest = 's3://' . Configure::readOrFail('Uppy.S3.bucket') . DS . $target; $manager = new Transfer($s3Client, $source, $dest); $manager->transfer(); @@ -121,7 +138,6 @@ public function uploadDir(string $source, string $target): void $promise = $manager->promise(); $promise->then(function (): void { - //Do nothing }); $promise->otherwise(function ($reason): void { @@ -130,8 +146,6 @@ public function uploadDir(string $source, string $target): void } /** - * Upload file using S3 putObject method - * * @param string $sourceFilePath string used as filesystem source file * @param string $destinationS3Path string used as path in S3 * @return void @@ -140,7 +154,7 @@ public function uploadDir(string $source, string $target): void */ public function uploadFile(string $sourceFilePath, string $destinationS3Path): void { - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); + $s3Client = $this->getS3Client(); $s3Options = [ 'Bucket' => Configure::readOrFail('Uppy.S3.bucket'), 'Key' => $destinationS3Path, @@ -160,15 +174,13 @@ public function uploadFile(string $sourceFilePath, string $destinationS3Path): v } /** - * Delete file using S3 deleteObject method - * * @see /config/cors.xml * @param string $fileS3Path string used as path in S3 * @return void */ public function deleteFile(string $fileS3Path): void { - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); + $s3Client = $this->getS3Client(); $s3Options = [ 'Bucket' => Configure::read('Uppy.S3.bucket'), 'Key' => $fileS3Path, @@ -177,8 +189,6 @@ public function deleteFile(string $fileS3Path): void } /** - * Delete dir on S3 usign matching rule - * * @param string $fileS3Path string used as path in S3 * @return void * @throws \Exception @@ -186,7 +196,7 @@ public function deleteFile(string $fileS3Path): void */ public function deleteDir(string $fileS3Path): void { - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); + $s3Client = $this->getS3Client(); if ($this->folderExists($fileS3Path)) { $s3Client->deleteMatchingObjects(Configure::readOrFail('Uppy.S3.bucket'), $fileS3Path); } else { @@ -195,36 +205,32 @@ public function deleteDir(string $fileS3Path): void } /** - * Check if file exists in S3 bucket - * * @param string $filename filename * @return bool */ public function fileExists(string $filename): bool { - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); + $s3Client = $this->getS3Client(); return $s3Client->doesObjectExist(Configure::readOrFail('Uppy.S3.bucket'), $filename); } /** - * Check if folder exists in S3 bucket - * * @param string $filename filename * @return bool * @throws \Exception */ public function folderExists(string $filename): bool { - $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config')); + $s3Client = $this->getS3Client(); $list = $s3Client->listObjectsV2([ 'Bucket' => Configure::readOrFail('Uppy.S3.bucket'), 'Prefix' => $filename, ]); if ($list['Contents'] > 0) { return true; - } else { - throw new Exception('Folder doesn\'t exist. Please try again.'); } + + throw new Exception('Folder doesn\'t exist. Please try again.'); } } diff --git a/tests/TestCase/Storage/AdapterFactoryTest.php b/tests/TestCase/Storage/AdapterFactoryTest.php new file mode 100644 index 0000000..725c27c --- /dev/null +++ b/tests/TestCase/Storage/AdapterFactoryTest.php @@ -0,0 +1,53 @@ +assertInstanceOf(S3Adapter::class, AdapterFactory::create()); + } + + public function testCreateReturnsGcsAdapterForGcsDriver(): void + { + Configure::write('Uppy.driver', 'gcs'); + $this->assertInstanceOf(GcsAdapter::class, AdapterFactory::create()); + } + + public function testCreateReturnsR2AdapterForR2Driver(): void + { + Configure::write('Uppy.driver', 'r2'); + $this->assertInstanceOf(R2Adapter::class, AdapterFactory::create()); + } + + public function testCreateDefaultsToS3WhenDriverNotSet(): void + { + Configure::delete('Uppy.driver'); + $this->assertInstanceOf(S3Adapter::class, AdapterFactory::create()); + } + + public function testCreateThrowsForUnknownDriver(): void + { + Configure::write('Uppy.driver', 'azure'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown Uppy driver: azure'); + AdapterFactory::create(); + } +} diff --git a/tests/TestCase/Storage/GcsAdapterTest.php b/tests/TestCase/Storage/GcsAdapterTest.php new file mode 100644 index 0000000..c7c1bfe --- /dev/null +++ b/tests/TestCase/Storage/GcsAdapterTest.php @@ -0,0 +1,109 @@ + 'test-project', + 'keyFilePath' => '/tmp/fake-key.json', + 'bucket' => 'test-bucket', + 'lifeTimePutObject' => '+5 minutes', + 'lifeTimeGetObject' => '+20 minutes', + ]); + + $this->mockObject = $this->createMock(StorageObject::class); + $this->mockBucket = $this->createMock(Bucket::class); + $this->mockBucket->method('object')->willReturn($this->mockObject); + $this->mockClient = $this->createMock(StorageClient::class); + $this->mockClient->method('bucket')->willReturn($this->mockBucket); + } + + protected function tearDown(): void + { + Configure::delete('Uppy.GCS'); + parent::tearDown(); + } + + public function testCreatePresignedRequestReturnsPsrRequestWithPutMethod(): void + { + $this->mockObject + ->method('signedUrl') + ->with($this->isInstanceOf(DateTime::class), $this->arrayHasKey('method')) + ->willReturn('https://storage.googleapis.com/signed-put-url'); + + $adapter = new GcsAdapter($this->mockClient); + $result = $adapter->createPresignedRequest('users/uuid/file.jpg', 'image/jpeg'); + + $this->assertInstanceOf(RequestInterface::class, $result); + $this->assertSame('PUT', $result->getMethod()); + $this->assertStringContainsString('signed-put-url', (string)$result->getUri()); + $this->assertSame('image/jpeg', $result->getHeaderLine('Content-Type')); + } + + public function testPresignedUrlReturnsSignedGetUrl(): void + { + $this->mockObject + ->method('signedUrl') + ->with($this->isInstanceOf(DateTime::class), $this->arrayHasKey('method')) + ->willReturn('https://storage.googleapis.com/signed-get-url'); + + $adapter = new GcsAdapter($this->mockClient); + $result = $adapter->presignedUrl('users/uuid/file.jpg', 3600); + + $this->assertSame('https://storage.googleapis.com/signed-get-url', $result); + } + + public function testDeleteObjectReturnsTrueOnSuccess(): void + { + $this->mockObject->expects($this->once())->method('delete'); + + $adapter = new GcsAdapter($this->mockClient); + $result = $adapter->deleteObject('users/uuid/file.jpg'); + + $this->assertTrue($result); + } + + public function testDeleteObjectReturnsFalseOnException(): void + { + $this->mockObject + ->method('delete') + ->willThrowException(new GoogleException('GCS error')); + + $adapter = new GcsAdapter($this->mockClient); + $result = $adapter->deleteObject('users/uuid/file.jpg'); + + $this->assertFalse($result); + } + + public function testDeleteObjectReturnsTrueWhenObjectNotFound(): void + { + $this->mockObject + ->method('delete') + ->willThrowException(new NotFoundException('Not found')); + + $adapter = new GcsAdapter($this->mockClient); + $result = $adapter->deleteObject('users/uuid/file.jpg'); + + $this->assertTrue($result); + } +} diff --git a/tests/TestCase/Storage/R2AdapterTest.php b/tests/TestCase/Storage/R2AdapterTest.php new file mode 100644 index 0000000..1af5449 --- /dev/null +++ b/tests/TestCase/Storage/R2AdapterTest.php @@ -0,0 +1,61 @@ + 'test-bucket', + 'publicDomain' => 'files.example.com', + 'constants' => [ + 'lifeTimePutObject' => '+5 minutes', + 'lifeTimeGetObject' => '+20 minutes', + ], + 'config' => [ + 'version' => 'latest', + 'region' => 'auto', + 'endpoint' => 'https://test.r2.cloudflarestorage.com', + 'credentials' => ['key' => 'fake', 'secret' => 'fake'], + 'connection' => 'dummy', + 'use_path_style_endpoint' => true, + ], + ]); + } + + protected function tearDown(): void + { + Configure::delete('Uppy.R2'); + parent::tearDown(); + } + + public function testCreatePresignedRequestReturnsPsrRequest(): void + { + $adapter = new R2Adapter(); + $result = $adapter->createPresignedRequest('users/uuid/file.jpg', 'image/jpeg'); + $this->assertInstanceOf(RequestInterface::class, $result); + $this->assertSame('https://example.com', (string)$result->getUri()); + } + + public function testPresignedUrlReturnsString(): void + { + $adapter = new R2Adapter(); + $result = $adapter->presignedUrl('users/uuid/file.jpg'); + $this->assertSame('https://example.com', $result); + } + + public function testDeleteObjectReturnsTrueWhenConnectionDummy(): void + { + $adapter = new R2Adapter(); + $result = $adapter->deleteObject('users/uuid/file.jpg'); + $this->assertTrue($result); + } +} diff --git a/tests/TestCase/Storage/S3AdapterTest.php b/tests/TestCase/Storage/S3AdapterTest.php new file mode 100644 index 0000000..b373888 --- /dev/null +++ b/tests/TestCase/Storage/S3AdapterTest.php @@ -0,0 +1,58 @@ + [ + 'lifeTimeGetObject' => '+20 minutes', + 'lifeTimePutObject' => '+5 minutes', + ], + 'config' => [ + 'version' => 'latest', + 'region' => 'us-east-1', + 'connection' => 'dummy', + 'credentials' => ['key' => 'fake', 'secret' => 'fake'], + ], + 'bucket' => 'test-bucket', + ]); + } + + protected function tearDown(): void + { + Configure::delete('Uppy.S3'); + parent::tearDown(); + } + + public function testCreatePresignedRequestReturnsPsrRequest(): void + { + $adapter = new S3Adapter(); + $result = $adapter->createPresignedRequest('users/uuid/file.jpg', 'image/jpeg'); + $this->assertInstanceOf(RequestInterface::class, $result); + $this->assertSame('https://example.com', (string)$result->getUri()); + } + + public function testPresignedUrlReturnsString(): void + { + $adapter = new S3Adapter(); + $result = $adapter->presignedUrl('users/uuid/file.jpg'); + $this->assertSame('https://example.com', $result); + } + + public function testDeleteObjectReturnsTrueWhenConnectionDummy(): void + { + $adapter = new S3Adapter(); + $result = $adapter->deleteObject('users/uuid/file.jpg'); + $this->assertTrue($result); + } +}